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
+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();