chore: bump version to 0.1652

This commit is contained in:
arkon
2026-02-27 00:34:38 +01:00
parent c50ccbf8a8
commit 5f1f66bf60
68 changed files with 9990 additions and 62 deletions
+248
View File
@@ -0,0 +1,248 @@
/**
* @fileoverview Cloudflare Tunnel Manager
*
* Manages a cloudflared child process for remote access to Codeman.
* Spawns `cloudflared tunnel --url` as a child process and parses
* the trycloudflare.com URL from its stderr output.
*
* Follows the same lifecycle pattern as ImageWatcher/SubagentWatcher:
* extends EventEmitter, start()/stop(), emits typed events.
*/
import { EventEmitter } from 'node:events';
import { spawn, type ChildProcess } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
// ========== Types ==========
export interface TunnelStatus {
running: boolean;
url: string | null;
}
// ========== Constants ==========
/** Regex to extract the trycloudflare.com URL from cloudflared output */
const TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
/** Max time to wait for URL before considering it a timeout (ms) */
const URL_TIMEOUT_MS = 30_000;
/** Restart delay after unexpected exit (ms) */
const RESTART_DELAY_MS = 5_000;
// ========== TunnelManager Class ==========
export class TunnelManager extends EventEmitter {
private process: ChildProcess | null = null;
private url: string | null = null;
private cloudflaredPath: string | null = null;
private urlTimeoutTimer: NodeJS.Timeout | null = null;
private restartTimer: NodeJS.Timeout | null = null;
private stopping = false;
private localPort = 3000;
private useHttps = false;
/**
* Resolve cloudflared binary path.
* Checks ~/.local/bin first, then falls back to PATH.
*/
private resolveCloudflared(): string | null {
if (this.cloudflaredPath) return this.cloudflaredPath;
// Check ~/.local/bin first (common user install location)
const localBin = join(homedir(), '.local', 'bin', 'cloudflared');
if (existsSync(localBin)) {
this.cloudflaredPath = localBin;
return localBin;
}
// Check /usr/local/bin
const usrLocalBin = '/usr/local/bin/cloudflared';
if (existsSync(usrLocalBin)) {
this.cloudflaredPath = usrLocalBin;
return usrLocalBin;
}
// Fall back to PATH
this.cloudflaredPath = 'cloudflared';
return 'cloudflared';
}
/**
* Start the cloudflared tunnel process.
*/
start(localPort: number, https: boolean): void {
if (this.process) {
return; // Already running
}
this.stopping = false;
this.localPort = localPort;
this.useHttps = https;
const binary = this.resolveCloudflared();
if (!binary) {
this.emit('error', 'cloudflared not found. Install from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/');
return;
}
const protocol = https ? 'https' : 'http';
const args = ['tunnel', '--url', `${protocol}://localhost:${localPort}`];
if (https) {
args.push('--no-tls-verify');
}
console.log(`[TunnelManager] Starting: ${binary} ${args.join(' ')}`);
try {
this.process = spawn(binary, args, {
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
} catch (err) {
this.emit('error', `Failed to spawn cloudflared: ${err instanceof Error ? err.message : String(err)}`);
return;
}
this.emit('progress', { message: 'Spawning cloudflared process...' });
// Parse stdout/stderr for the URL, then detach once found
const handleOutput = (data: Buffer) => {
const line = data.toString().trim();
if (!line) return;
// Emit progress for interesting cloudflared log lines
if (!this.url) {
if (/connector.*registered/i.test(line)) {
this.emit('progress', { message: 'Tunnel connector registered' });
} else if (/connection.*registered/i.test(line)) {
this.emit('progress', { message: 'Connection registered with Cloudflare edge' });
} else if (/route.*propagating/i.test(line) || /ingress/i.test(line)) {
this.emit('progress', { message: 'Propagating route to Cloudflare edge...' });
} else if (/Starting tunnel/i.test(line) || /initial.*connection/i.test(line)) {
this.emit('progress', { message: 'Establishing tunnel connection...' });
} else if (/Registered tunnel connection/i.test(line)) {
this.emit('progress', { message: 'Tunnel connection registered' });
}
}
const match = line.match(TUNNEL_URL_REGEX);
if (match && !this.url) {
this.url = match[0];
console.log(`[TunnelManager] Tunnel URL: ${this.url}`);
if (this.urlTimeoutTimer) {
clearTimeout(this.urlTimeoutTimer);
this.urlTimeoutTimer = null;
}
// Detach listeners — no need to parse further output
this.process?.stdout?.off('data', handleOutput);
this.process?.stderr?.off('data', handleOutput);
this.emit('started', { url: this.url });
}
};
this.process.stdout?.on('data', handleOutput);
this.process.stderr?.on('data', handleOutput);
this.process.on('error', (err) => {
console.error(`[TunnelManager] Process error:`, err.message);
this.cleanup();
this.emit('error', `cloudflared error: ${err.message}`);
});
this.process.on('exit', (code, signal) => {
console.log(`[TunnelManager] Process exited (code=${code}, signal=${signal})`);
const wasRunning = this.url !== null;
this.cleanup();
if (!this.stopping) {
// Unexpected exit — attempt restart
this.emit('error', `cloudflared exited unexpectedly (code=${code})`);
if (wasRunning) {
console.log(`[TunnelManager] Scheduling restart in ${RESTART_DELAY_MS}ms`);
this.restartTimer = setTimeout(() => {
this.restartTimer = null;
if (!this.stopping && !this.process) {
this.start(this.localPort, this.useHttps);
}
}, RESTART_DELAY_MS);
}
} else {
this.emit('stopped', {});
}
});
// Set URL timeout
this.urlTimeoutTimer = setTimeout(() => {
this.urlTimeoutTimer = null;
if (!this.url && this.process) {
this.emit('error', 'Timed out waiting for tunnel URL');
}
}, URL_TIMEOUT_MS);
}
/**
* Stop the cloudflared tunnel process.
*/
stop(): void {
this.stopping = true;
if (this.restartTimer) {
clearTimeout(this.restartTimer);
this.restartTimer = null;
}
if (this.urlTimeoutTimer) {
clearTimeout(this.urlTimeoutTimer);
this.urlTimeoutTimer = null;
}
if (this.process) {
console.log(`[TunnelManager] Stopping tunnel (PID ${this.process.pid})`);
this.process.kill('SIGTERM');
// Force kill after 5s if still alive
const pid = this.process.pid;
const forceTimer = setTimeout(() => {
try {
if (pid) process.kill(pid, 'SIGKILL');
} catch {
// Process already gone
}
}, 5000);
this.process.once('exit', () => clearTimeout(forceTimer));
} else {
this.cleanup();
this.emit('stopped', {});
}
}
/**
* Clean up internal state after process exit.
*/
private cleanup(): void {
this.process = null;
this.url = null;
if (this.urlTimeoutTimer) {
clearTimeout(this.urlTimeoutTimer);
this.urlTimeoutTimer = null;
}
}
isRunning(): boolean {
return this.process !== null;
}
getUrl(): string | null {
return this.url;
}
getStatus(): TunnelStatus {
return {
running: this.process !== null,
url: this.url,
};
}
}
+289
View File
@@ -3466,6 +3466,7 @@ class CodemanApp {
const overlay = document.getElementById('welcomeOverlay');
if (overlay) {
overlay.classList.add('visible');
this.loadTunnelStatus();
}
}
@@ -3474,6 +3475,12 @@ class CodemanApp {
if (overlay) {
overlay.classList.remove('visible');
}
// Collapse expanded QR when leaving welcome screen
const qrWrap = document.getElementById('welcomeQr');
if (qrWrap) {
clearTimeout(this._welcomeQrShrinkTimer);
qrWrap.classList.remove('expanded');
}
}
/**
@@ -4894,6 +4901,57 @@ class CodemanApp {
this.openImagePopup(data);
});
// ========== Tunnel Events ==========
addListener('tunnel:started', (e) => {
const data = JSON.parse(e.data);
console.log('[Tunnel] Started:', data.url);
this._dismissTunnelConnecting();
this._updateTunnelUrlDisplay(data.url);
const welcomeVisible = document.getElementById('welcomeOverlay')?.classList.contains('visible');
if (welcomeVisible) {
// On welcome screen: QR appears inline, expanded first
this._updateWelcomeTunnelBtn(true, data.url, true);
this.showToast(`Tunnel active`, 'success');
} else {
// Not on welcome screen: popup QR overlay
this._updateWelcomeTunnelBtn(true, data.url);
this.showToast(`Tunnel active: ${data.url}`, 'success');
this.showTunnelQR();
}
});
addListener('tunnel:stopped', () => {
console.log('[Tunnel] Stopped');
this._dismissTunnelConnecting();
this._updateTunnelUrlDisplay(null);
this._updateWelcomeTunnelBtn(false);
this.closeTunnelQR();
});
addListener('tunnel:progress', (e) => {
const data = JSON.parse(e.data);
console.log('[Tunnel] Progress:', data.message);
const toast = document.getElementById('tunnelConnectingToast');
if (toast) {
toast.innerHTML = `<span class="tunnel-spinner"></span> ${data.message}`;
}
// Also update button text if on welcome screen
const btn = document.getElementById('welcomeTunnelBtn');
if (btn?.classList.contains('connecting')) {
btn.innerHTML = `<span class="tunnel-spinner"></span> ${data.message}`;
}
});
addListener('tunnel:error', (e) => {
const data = JSON.parse(e.data);
console.warn('[Tunnel] Error:', data.message);
this._dismissTunnelConnecting();
this.showToast(`Tunnel error: ${data.message}`, 'error');
const btn = document.getElementById('welcomeTunnelBtn');
if (btn) { btn.disabled = false; btn.classList.remove('connecting'); }
});
// Plan subagent visibility events (show Opus agents during plan generation)
addListener('plan:subagent', (e) => {
const data = JSON.parse(e.data);
@@ -11389,6 +11447,8 @@ class CodemanApp {
document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true;
document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true;
document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false;
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
this.loadTunnelStatus();
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
// Claude CLI settings
@@ -11515,6 +11575,229 @@ class CodemanApp {
}
}
async loadTunnelStatus() {
try {
const res = await fetch('/api/tunnel/status');
const status = await res.json();
const active = status.running && status.url;
this._updateTunnelUrlDisplay(active ? status.url : null);
this._updateWelcomeTunnelBtn(!!active, active ? status.url : null);
} catch {
this._updateTunnelUrlDisplay(null);
this._updateWelcomeTunnelBtn(false);
}
}
_updateTunnelUrlDisplay(url) {
const row = document.getElementById('tunnelUrlRow');
const display = document.getElementById('tunnelUrlDisplay');
if (!row || !display) return;
if (url) {
row.style.display = '';
display.textContent = url;
display.onclick = () => {
navigator.clipboard.writeText(url).then(() => {
this.showToast('Tunnel URL copied', 'success');
});
};
} else {
row.style.display = 'none';
display.textContent = '';
display.onclick = null;
}
}
showTunnelQR() {
// Close existing popup if open
this.closeTunnelQR();
const overlay = document.createElement('div');
overlay.id = 'tunnelQrOverlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:5000;display:flex;align-items:center;justify-content:center;cursor:pointer';
overlay.onclick = (e) => { if (e.target === overlay) this.closeTunnelQR(); };
const card = document.createElement('div');
card.style.cssText = 'background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:24px;text-align:center;max-width:340px;width:90vw;box-shadow:var(--shadow-lg);cursor:default';
card.innerHTML = `
<div style="font-size:14px;font-weight:600;color:var(--text-primary);margin-bottom:16px">Scan to connect</div>
<div id="tunnelQrContainer" style="background:#fff;border-radius:8px;padding:16px;display:inline-block">
<div style="color:#666;font-size:12px">Loading...</div>
</div>
<div id="tunnelQrUrl" style="margin-top:12px;font-family:monospace;font-size:11px;color:var(--text-muted);word-break:break-all;cursor:pointer" title="Click to copy"></div>
<button onclick="app.closeTunnelQR()" style="margin-top:16px;padding:6px 20px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);cursor:pointer;font-size:13px">Close</button>
`;
overlay.appendChild(card);
document.body.appendChild(overlay);
// Fetch QR SVG from server
fetch('/api/tunnel/qr')
.then(res => {
if (!res.ok) throw new Error('Tunnel not running');
return res.json();
})
.then(data => {
const container = document.getElementById('tunnelQrContainer');
if (container && data.svg) container.innerHTML = data.svg;
})
.catch(() => {
const container = document.getElementById('tunnelQrContainer');
if (container) container.innerHTML = '<div style="color:#c00;font-size:12px;padding:20px">Tunnel not active</div>';
});
// Fetch URL for display
fetch('/api/tunnel/status')
.then(r => r.json())
.then(status => {
const urlEl = document.getElementById('tunnelQrUrl');
if (urlEl && status.url) {
urlEl.textContent = status.url;
urlEl.onclick = () => {
navigator.clipboard.writeText(status.url).then(() => {
this.showToast('Tunnel URL copied', 'success');
});
};
}
})
.catch(() => {});
// Close on Escape
this._tunnelQrEscHandler = (e) => { if (e.key === 'Escape') this.closeTunnelQR(); };
document.addEventListener('keydown', this._tunnelQrEscHandler);
}
closeTunnelQR() {
const overlay = document.getElementById('tunnelQrOverlay');
if (overlay) overlay.remove();
if (this._tunnelQrEscHandler) {
document.removeEventListener('keydown', this._tunnelQrEscHandler);
this._tunnelQrEscHandler = null;
}
}
async toggleTunnelFromWelcome() {
const btn = document.getElementById('welcomeTunnelBtn');
if (!btn) return;
const isActive = btn.classList.contains('active');
btn.disabled = true;
try {
const res = await fetch('/api/settings');
const current = res.ok ? await res.json() : {};
const newEnabled = !isActive;
current.tunnelEnabled = newEnabled;
await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(current),
});
if (newEnabled) {
this._showTunnelConnecting();
} else {
this._dismissTunnelConnecting();
this.showToast('Tunnel stopped', 'info');
this._updateWelcomeTunnelBtn(false);
btn.disabled = false;
}
} catch (err) {
this._dismissTunnelConnecting();
this.showToast('Failed to toggle tunnel', 'error');
btn.disabled = false;
}
}
_showTunnelConnecting() {
const btn = document.getElementById('welcomeTunnelBtn');
if (btn) {
btn.classList.add('connecting');
btn.innerHTML = `
<span class="tunnel-spinner"></span>
Connecting...`;
}
// Persistent toast with spinner
this._dismissTunnelConnecting();
const toast = document.createElement('div');
toast.className = 'toast toast-info show';
toast.id = 'tunnelConnectingToast';
toast.innerHTML = '<span class="tunnel-spinner"></span> Cloudflare Tunnel connecting...';
toast.style.pointerEvents = 'auto';
if (!this._toastContainer) {
this._toastContainer = document.querySelector('.toast-container');
if (!this._toastContainer) {
this._toastContainer = document.createElement('div');
this._toastContainer.className = 'toast-container';
document.body.appendChild(this._toastContainer);
}
}
this._toastContainer.appendChild(toast);
}
_dismissTunnelConnecting() {
const toast = document.getElementById('tunnelConnectingToast');
if (toast) {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 200);
}
const btn = document.getElementById('welcomeTunnelBtn');
if (btn) btn.classList.remove('connecting');
}
_updateWelcomeTunnelBtn(active, url, firstAppear = false) {
const btn = document.getElementById('welcomeTunnelBtn');
if (btn) {
btn.disabled = false;
if (active) {
btn.classList.remove('connecting');
btn.classList.add('active');
btn.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
Tunnel Active`;
} else {
btn.classList.remove('active', 'connecting');
btn.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
Cloudflare Tunnel`;
}
}
// Update welcome QR code
const qrWrap = document.getElementById('welcomeQr');
const qrInner = document.getElementById('welcomeQrInner');
const qrUrl = document.getElementById('welcomeQrUrl');
if (!qrWrap || !qrInner) return;
if (active) {
qrWrap.classList.add('visible');
// First appear: start expanded, auto-shrink after 8s
if (firstAppear) {
qrWrap.classList.add('expanded');
clearTimeout(this._welcomeQrShrinkTimer);
this._welcomeQrShrinkTimer = setTimeout(() => {
qrWrap.classList.remove('expanded');
}, 8000);
}
if (url) {
qrUrl.textContent = url;
qrUrl.title = 'Click QR to enlarge';
}
fetch('/api/tunnel/qr')
.then(r => { if (!r.ok) throw new Error(); return r.json(); })
.then(data => { if (data.svg) qrInner.innerHTML = data.svg; })
.catch(() => { qrInner.innerHTML = '<div style="color:#999;font-size:11px;padding:20px">QR unavailable</div>'; });
} else {
clearTimeout(this._welcomeQrShrinkTimer);
qrWrap.classList.remove('visible', 'expanded');
qrInner.innerHTML = '';
if (qrUrl) qrUrl.textContent = '';
}
}
toggleWelcomeQrSize() {
const qrWrap = document.getElementById('welcomeQr');
if (qrWrap) {
clearTimeout(this._welcomeQrShrinkTimer);
qrWrap.classList.toggle('expanded');
}
}
toggleDeepgramKeyVisibility() {
const input = document.getElementById('voiceDeepgramKey');
const btn = document.getElementById('voiceKeyToggleBtn');
@@ -11642,6 +11925,7 @@ class CodemanApp {
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
// Claude CLI settings
@@ -11760,6 +12044,11 @@ class CodemanApp {
await this.saveModelConfigFromSettings();
this.showToast('Settings saved', 'success');
// Show tunnel-specific feedback if toggled on
if (settings.tunnelEnabled) {
this.showToast('Tunnel starting — QR code will appear when ready...', 'info');
}
} catch (err) {
// Server save failed but localStorage succeeded
this.showToast('Settings saved locally', 'warning');
+28 -4
View File
@@ -238,11 +238,19 @@
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run Claude Code
</button>
<button class="welcome-btn welcome-btn-tunnel" id="welcomeTunnelBtn" onclick="app.toggleTunnelFromWelcome()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
Cloudflare Tunnel
</button>
<button class="welcome-btn welcome-btn-opencode" onclick="app.setRunMode('opencode'); app.runOpenCode()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run OpenCode
</button>
</div>
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
<div class="welcome-qr-url" id="welcomeQrUrl"></div>
</div>
<p class="welcome-hint">Or press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> to start</p>
</div>
</div>
@@ -379,10 +387,6 @@
</div>
<div class="toolbar-center">
<span class="version-display" id="versionDisplay" title="Codeman version">v0.0.0</span>
</div>
<div class="toolbar-right">
<button class="btn-toolbar btn-sm btn-voice" id="voiceInputBtn" onclick="VoiceInput.toggle()"
title="Voice input (Ctrl+Shift+V)" aria-label="Start voice input" aria-pressed="false">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -393,6 +397,10 @@
</svg>
</button>
</div>
<div class="toolbar-right">
<span class="version-display" id="versionDisplay" title="Codeman version">v0.0.0</span>
</div>
</footer>
<!-- Help Modal -->
@@ -934,6 +942,22 @@
</label>
</div>
<!-- Remote Access Section -->
<div class="settings-section-header">Remote Access</div>
<div class="settings-item" title="Expose Codeman via Cloudflare Tunnel for remote access">
<span class="settings-item-label">Cloudflare Tunnel</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsTunnelEnabled">
<span class="slider"></span>
</label>
</div>
<div class="settings-item" id="tunnelUrlRow" style="display:none">
<span class="settings-item-label">Tunnel URL</span>
<div style="display:flex; align-items:center; gap:8px">
<span id="tunnelUrlDisplay" class="settings-item-value" style="cursor:pointer; text-decoration:underline; font-family:monospace; font-size:12px" title="Click to copy"></span>
<button class="btn-icon-sm" id="tunnelQrBtn" onclick="app.showTunnelQR()" title="Show QR code" style="font-size:16px; padding:2px 6px; background:none; border:1px solid var(--border); border-radius:4px; cursor:pointer; color:var(--text-secondary)">⊞</button>
</div>
</div>
</div>
</div>
+104 -6
View File
@@ -1685,11 +1685,11 @@ body {
}
.welcome-title {
font-size: 1.75rem;
font-weight: 600;
font-size: 3.5rem;
font-weight: 700;
color: var(--accent-hover);
margin-bottom: 1rem;
letter-spacing: -0.02em;
letter-spacing: -0.03em;
}
.welcome-desc {
@@ -1703,6 +1703,7 @@ body {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-top: 2rem;
margin-bottom: 1.5rem;
}
@@ -1744,6 +1745,52 @@ body {
color: #a7f3d0;
}
.welcome-btn-tunnel {
background: linear-gradient(135deg, #2a1a3e 0%, #6d28d9 100%);
border-color: #7c3aed;
color: #ddd6fe;
}
.welcome-btn-tunnel:hover {
background: linear-gradient(135deg, #6d28d9 0%, #7c3aed 100%);
box-shadow: 0 0 12px rgba(124, 58, 237, 0.4);
color: #fff;
}
.welcome-btn-tunnel.active {
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
border-color: #34d399;
color: #ecfdf5;
}
.welcome-btn-tunnel.active:hover {
background: linear-gradient(135deg, #10b981 0%, #34d399 100%);
box-shadow: 0 0 12px rgba(52, 211, 153, 0.4);
color: #fff;
}
.welcome-btn-tunnel.connecting {
background: linear-gradient(135deg, #1e1e3a 0%, #4338ca 100%);
border-color: #6366f1;
color: #c7d2fe;
cursor: wait;
}
.tunnel-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(255,255,255,0.25);
border-top-color: currentColor;
border-radius: 50%;
animation: tunnel-spin 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes tunnel-spin {
to { transform: rotate(360deg); }
}
.welcome-btn-ralph {
background: linear-gradient(135deg, #3d3415 0%, #4a3f1a 100%);
border-color: #6b5a1e;
@@ -1757,6 +1804,50 @@ body {
color: #fcd34d;
}
.welcome-qr {
display: none;
flex-direction: column;
align-items: center;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
cursor: pointer;
transition: transform 0.2s ease;
}
.welcome-qr.visible {
display: flex;
}
.welcome-qr-inner {
background: #fff;
border-radius: 10px;
padding: 12px;
width: 140px;
height: 140px;
transition: width 0.25s ease, height 0.25s ease, padding 0.25s ease;
}
.welcome-qr-inner svg {
width: 100%;
height: 100%;
}
.welcome-qr.expanded .welcome-qr-inner {
width: 280px;
height: 280px;
padding: 16px;
}
.welcome-qr-url {
margin-top: 8px;
font-family: monospace;
font-size: 11px;
color: var(--text-muted);
word-break: break-all;
max-width: 320px;
text-align: center;
}
.welcome-hint {
color: var(--text-muted);
font-size: 0.8rem;
@@ -1804,13 +1895,14 @@ body {
.version-display {
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.7rem;
font-size: 0.6rem;
color: var(--text-muted);
padding: 0.2rem 0.5rem;
padding: 0.15rem 0.4rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
cursor: default;
user-select: none;
opacity: 0.7;
}
.toolbar-group {
@@ -2081,11 +2173,17 @@ body {
border-color: var(--red);
}
/* Voice Input Button */
/* Voice Input Button — desktop only (mobile uses .btn-voice-mobile) */
.btn-toolbar.btn-voice {
padding: 0.3rem 0.5rem;
}
@media (max-width: 1023px) {
.toolbar-center .btn-toolbar.btn-voice {
display: none !important;
}
}
.btn-toolbar.btn-voice:hover {
color: var(--text);
border-color: rgba(59, 130, 246, 0.4);
+166 -6
View File
@@ -12,12 +12,14 @@
import Fastify, { FastifyInstance, FastifyReply } from 'fastify';
import fastifyCompress from '@fastify/compress';
import fastifyCookie from '@fastify/cookie';
import fastifyStatic from '@fastify/static';
import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, statSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import fs from 'node:fs/promises';
import { execSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os';
import { EventEmitter } from 'node:events';
import { Session, ClaudeMessage, type BackgroundTask, type RalphTrackerState, type RalphTodoItem, type ActiveBashTool } from '../session.js';
@@ -34,6 +36,7 @@ import { subagentWatcher, type SubagentInfo, type SubagentToolCall, type Subagen
import { imageWatcher } from '../image-watcher.js';
import { TranscriptWatcher } from '../transcript-watcher.js';
import { TeamWatcher } from '../team-watcher.js';
import { TunnelManager } from '../tunnel-manager.js';
import { v4 as uuidv4 } from 'uuid';
import { createRequire } from 'node:module';
import { RunSummaryTracker } from '../run-summary.js';
@@ -89,6 +92,7 @@ import {
SubagentParentMapSchema,
InteractiveRespawnSchema,
RespawnEnableSchema,
isValidWorkingDir,
} from './schemas.js';
import { StaleExpirationMap } from '../utils/index.js';
import { MAX_CONCURRENT_SESSIONS } from '../config/map-limits.js';
@@ -141,6 +145,16 @@ const MAX_SESSION_NAME_LENGTH = 128;
const MAX_HOOK_DATA_SIZE = 8 * 1024;
// Maximum screenshot upload size (10MB)
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
// Auth session cookie TTL (24h — matches autonomous run length)
const AUTH_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
// Auth session cookie name
const AUTH_COOKIE_NAME = 'codeman_session';
// Max concurrent auth sessions
const MAX_AUTH_SESSIONS = 100;
// Max failed auth attempts per IP before rate-limiting
const AUTH_FAILURE_MAX = 10;
// Failed auth attempt tracking window (15 minutes)
const AUTH_FAILURE_WINDOW_MS = 15 * 60 * 1000;
// Screenshots directory
const SCREENSHOTS_DIR = join(homedir(), '.codeman', 'screenshots');
// Stats collection interval (2 seconds)
@@ -408,6 +422,9 @@ export class WebServer extends EventEmitter {
detected: (event: ImageDetectedEvent) => void;
error: (error: Error, sessionId?: string) => void;
} | null = null;
private tunnelManager: TunnelManager = new TunnelManager();
private authSessions: StaleExpirationMap<string, string> | null = null;
private authFailures: StaleExpirationMap<string, number> | null = null;
private teamWatcher: TeamWatcher = new TeamWatcher();
private teamWatcherHandlers: {
teamCreated: (config: unknown) => void;
@@ -457,6 +474,20 @@ export class WebServer extends EventEmitter {
// Set up team watcher listeners
this.setupTeamWatcherListeners();
// Set up tunnel manager listeners
this.tunnelManager.on('started', (data: { url: string }) => {
this.broadcast('tunnel:started', data);
});
this.tunnelManager.on('stopped', () => {
this.broadcast('tunnel:stopped', {});
});
this.tunnelManager.on('error', (message: string) => {
this.broadcast('tunnel:error', { message });
});
this.tunnelManager.on('progress', (data: { message: string }) => {
this.broadcast('tunnel:progress', data);
});
}
/**
@@ -582,17 +613,82 @@ export class WebServer extends EventEmitter {
threshold: 1024,
});
// Optional HTTP Basic Auth (set CODEMAN_PASSWORD env var to enable)
// Cookie plugin (needed for auth session tokens)
await this.app.register(fastifyCookie);
// Optional HTTP Basic Auth with session cookies and rate limiting
const authPassword = process.env.CODEMAN_PASSWORD;
if (authPassword) {
const authUsername = process.env.CODEMAN_USERNAME || 'admin';
const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64');
// Session token store — active sessions extend TTL on access
this.authSessions = new StaleExpirationMap<string, string>({
ttlMs: AUTH_SESSION_TTL_MS,
refreshOnGet: true,
});
// Failure counter per IP — decay naturally after 15 minutes
this.authFailures = new StaleExpirationMap<string, number>({
ttlMs: AUTH_FAILURE_WINDOW_MS,
refreshOnGet: false,
});
this.app.addHook('onRequest', (req, reply, done) => {
const auth = req.headers.authorization;
if (auth === expectedHeader) {
// Hook events come from local Claude Code hooks (curl from localhost) — no auth headers available.
// Safe: validated by HookEventSchema, only triggers broadcasts.
if (req.url === '/api/hook-event' && req.method === 'POST') {
done();
return;
}
const clientIp = req.ip;
// Rate limit: reject if too many failed attempts from this IP
const failures = this.authFailures!.get(clientIp) ?? 0;
if (failures >= AUTH_FAILURE_MAX) {
reply.code(429).send('Too Many Requests — try again later');
return;
}
// Check session cookie first (avoids re-sending credentials on every request)
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
if (sessionToken && this.authSessions!.has(sessionToken)) {
done();
return;
}
// Check Basic Auth header
const auth = req.headers.authorization;
if (auth === expectedHeader) {
// Issue session token cookie so browser doesn't need to re-send credentials
const token = randomBytes(32).toString('hex');
// Evict oldest if at capacity (prevent unbounded growth)
if (this.authSessions!.size >= MAX_AUTH_SESSIONS) {
const oldestKey = this.authSessions!.keys().next().value;
if (oldestKey !== undefined) this.authSessions!.delete(oldestKey);
}
this.authSessions!.set(token, clientIp);
// Reset failure count on successful auth
this.authFailures!.delete(clientIp);
reply.setCookie(AUTH_COOKIE_NAME, token, {
httpOnly: true,
secure: this.https,
sameSite: 'lax',
maxAge: AUTH_SESSION_TTL_MS / 1000, // seconds
path: '/',
});
done();
return;
}
// Auth failed — track failure count
this.authFailures!.set(clientIp, failures + 1);
reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
reply.code(401).send('Unauthorized');
});
@@ -668,6 +764,23 @@ export class WebServer extends EventEmitter {
// API Routes
this.app.get('/api/status', async () => this.getLightState());
this.app.get('/api/tunnel/status', async () => this.tunnelManager.getStatus());
this.app.get('/api/tunnel/qr', async (_req, reply) => {
const url = this.tunnelManager.getUrl();
if (!url) {
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Tunnel not running'));
}
try {
const QRCode = require('qrcode');
const svg: string = await QRCode.toString(url, { type: 'svg', margin: 2, width: 256 });
// Return as data URI to avoid Fastify compress issues with SVG content-type
return { svg };
} catch (err) {
return reply.code(500).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)));
}
});
// OpenCode CLI availability check
this.app.get('/api/opencode/status', async () => {
const { isOpenCodeAvailable, resolveOpenCodeDir } = await import('../utils/opencode-cli-resolver.js');
@@ -3426,6 +3539,18 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
console.log('Image watcher stopped via settings change');
}
// Handle tunnel toggle dynamically
if ('tunnelEnabled' in settings) {
const tunnelEnabled = settings.tunnelEnabled as boolean;
if (tunnelEnabled && !this.tunnelManager.isRunning()) {
this.tunnelManager.start(this.port, this.https);
console.log('Tunnel started via settings change');
} else if (!tunnelEnabled && this.tunnelManager.isRunning()) {
this.tunnelManager.stop();
console.log('Tunnel stopped via settings change');
}
}
return { success: true };
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
@@ -3744,10 +3869,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
}
// Start transcript watching if transcript_path is provided
// Start transcript watching if transcript_path is provided and safe
if (data && 'transcript_path' in data) {
const transcriptPath = String(data.transcript_path);
if (transcriptPath) {
if (transcriptPath && isValidWorkingDir(transcriptPath)) {
this.startTranscriptWatcher(sessionId, transcriptPath);
}
}
@@ -5329,6 +5454,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
console.log('Image watcher disabled by user settings');
}
// Start Cloudflare tunnel if enabled in settings
if (await this.isTunnelEnabled()) {
this.tunnelManager.start(this.port, this.https);
console.log('Cloudflare tunnel starting on boot (enabled in settings)');
}
// Start team watcher for agent team awareness (always on — lightweight polling)
this.teamWatcher.start();
console.log('Team watcher started - monitoring ~/.claude/teams/ for agent team activity');
@@ -5371,6 +5502,23 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
return false; // Default disabled (matches UI default)
}
/**
* Check if Cloudflare tunnel is enabled in settings (default: false)
*/
private async isTunnelEnabled(): Promise<boolean> {
const settingsPath = join(homedir(), '.codeman', 'settings.json');
try {
const content = await fs.readFile(settingsPath, 'utf-8');
const settings = JSON.parse(content);
return settings.tunnelEnabled ?? false;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('Failed to read tunnel setting:', err);
}
}
return false;
}
private async restoreMuxSessions(): Promise<void> {
try {
// Reconcile mux sessions to find which ones are still alive (also discovers unknown ones)
@@ -5694,6 +5842,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// Stop team watcher
this.teamWatcher.stop();
// Stop tunnel
this.tunnelManager.stop();
this.tunnelManager.removeAllListeners();
// Destroy file stream manager (clears cleanup timer and kills remaining tail processes)
fileStreamManager.destroy();
@@ -5715,8 +5867,16 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
this.transcriptWatchers.clear();
this.sessionListenerRefs.clear();
this.scheduledRuns.clear();
// Dispose StaleExpirationMap (stops internal cleanup timer)
// Dispose StaleExpirationMaps (stops internal cleanup timers)
this.lastTerminalEventTime.dispose();
if (this.authSessions) {
this.authSessions.dispose();
this.authSessions = null;
}
if (this.authFailures) {
this.authFailures.dispose();
this.authFailures = null;
}
this.activePlanOrchestrators.clear();
this.cleaningUp.clear();