mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 06:29:42 +02:00
chore: bump version to 0.1652
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user