/** * @fileoverview System, config, settings, subagent, and debug routes. * Covers status, stats, config CRUD, settings, subagent monitoring, * debug/memory, lifecycle logs, screenshots, and various persistence endpoints. */ import { FastifyInstance } from 'fastify'; import { join, dirname } from 'node:path'; import { existsSync, mkdirSync, readdirSync } from 'node:fs'; import fs from 'node:fs/promises'; import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os'; import { execSync } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js'; import { ConfigUpdateSchema, SettingsUpdateSchema, ModelConfigUpdateSchema, CpuLimitSchema, SubagentWindowStatesSchema, SubagentParentMapSchema, RevokeSessionSchema, } from '../schemas.js'; import { subagentWatcher } from '../../subagent-watcher.js'; import { imageWatcher } from '../../image-watcher.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js'; import { findSessionOrFail, formatUptime, parseBody, readJsonConfig, toggleService, SETTINGS_PATH, } from '../route-helpers.js'; import { SseEvent } from '../sse-events.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js'; import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js'; // Maximum screenshot upload size (10MB) const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024; // Screenshots directory const SCREENSHOTS_DIR = join(homedir(), '.codeman', 'screenshots'); /** Cached CPU count — doesn't change at runtime */ const CPU_COUNT = cpus().length; /** Get system CPU and memory usage */ function getSystemStats(): { cpu: number; memory: { usedMB: number; totalMB: number; percent: number }; } { try { const totalMem = totalmem(); // macOS: os.freemem() only returns truly free pages, not cached/purgeable memory. // Use vm_stat to get accurate used memory (wired + active + compressed). let usedMem: number; if (process.platform === 'darwin') { try { const vmstat = execSync('vm_stat', { encoding: 'utf-8', timeout: 2000 }); const pageSize = parseInt(vmstat.match(/page size of (\d+)/)?.[1] || '4096', 10); const wired = parseInt(vmstat.match(/Pages wired down:\s+(\d+)/)?.[1] || '0', 10); const active = parseInt(vmstat.match(/Pages active:\s+(\d+)/)?.[1] || '0', 10); const compressed = parseInt(vmstat.match(/Pages occupied by compressor:\s+(\d+)/)?.[1] || '0', 10); usedMem = (wired + active + compressed) * pageSize; } catch { usedMem = totalMem - freemem(); } } else { usedMem = totalMem - freemem(); } // CPU load average (1 min) as percentage (rough approximation) const load = loadavg()[0]; const cpuPercent = Math.min(100, Math.round((load / CPU_COUNT) * 100)); return { cpu: cpuPercent, memory: { usedMB: Math.round(usedMem / (1024 * 1024)), totalMB: Math.round(totalMem / (1024 * 1024)), percent: Math.round((usedMem / totalMem) * 100), }, }; } catch { return { cpu: 0, memory: { usedMB: 0, totalMB: 0, percent: 0 }, }; } } export function registerSystemRoutes( app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort ): void { const windowStatesPath = join(homedir(), '.codeman', 'subagent-window-states.json'); const parentMapPath = join(homedir(), '.codeman', 'subagent-parents.json'); // ═══════════════════════════════════════════════════════════════ // System Status & Health // ═══════════════════════════════════════════════════════════════ // ========== Status ========== app.get('/api/status', async () => ctx.getLightState()); // ========== Tunnel ========== app.get('/api/tunnel/status', async () => ctx.tunnelManager.getStatus()); app.get('/api/tunnel/info', async () => { const status = ctx.tunnelManager.getStatus(); const sseClients = ctx.getSseClientCount(); const sessions: Array<{ ip: string; ua: string; createdAt: number; method: string }> = []; if (ctx.authSessions) { for (const [, record] of ctx.authSessions) { sessions.push({ ip: record.ip, ua: record.ua, createdAt: record.createdAt, method: record.method }); } } return { ...status, sseClients, authEnabled: !!process.env.CODEMAN_PASSWORD, authSessions: sessions, }; }); app.get('/api/tunnel/qr', async (_req, reply) => { const url = ctx.tunnelManager.getUrl(); if (!url) { return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Tunnel not running')); } try { const authPassword = process.env.CODEMAN_PASSWORD; if (authPassword) { // Auth enabled — use cached SVG with embedded short code const svg = await ctx.tunnelManager.getQrSvg(url); return { svg, authEnabled: true }; } // No auth — just encode the raw tunnel URL const QRCode = await import('qrcode'); const svg: string = await QRCode.toString(url, { type: 'svg', margin: 2, width: 256 }); return { svg, authEnabled: false }; } catch (err) { return reply.code(500).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err))); } }); // ═══════════════════════════════════════════════════════════════ // Authentication (QR auth, session revocation) // ═══════════════════════════════════════════════════════════════ // ========== QR Auth Route ========== app.get('/q/:code', async (req, reply) => { const shortCode = (req.params as { code: string }).code; const authPassword = process.env.CODEMAN_PASSWORD; // No point if auth isn't enabled — just redirect if (!authPassword) { return reply.redirect('/'); } const clientIp = req.ip; // Per-IP rate limit (separate counter from Basic Auth failures) const qrFailures = ctx.qrAuthFailures?.get(clientIp) ?? 0; if (qrFailures >= QR_AUTH_FAILURE_MAX) { return reply.code(429).send('Too Many Requests'); } // Validate and atomically consume the token if (!shortCode || !ctx.tunnelManager.consumeToken(shortCode)) { ctx.qrAuthFailures?.set(clientIp, qrFailures + 1); return reply.code(401).send('Invalid or expired QR code'); } // Issue session cookie (same pattern as Basic Auth success path) const sessionToken = randomBytes(32).toString('hex'); const clientUA = req.headers['user-agent'] ?? ''; ctx.authSessions?.set(sessionToken, { ip: clientIp, ua: clientUA, createdAt: Date.now(), method: 'qr', }); ctx.qrAuthFailures?.delete(clientIp); // Audit log const lifecycleLog = getLifecycleLog(); lifecycleLog.log({ event: 'qr_auth', sessionId: 'system', extra: { ip: clientIp, ua: clientUA, shortCodePrefix: shortCode.slice(0, 3) + '***', }, }); reply.setCookie(AUTH_COOKIE_NAME, sessionToken, { httpOnly: true, secure: ctx.https, sameSite: 'lax', maxAge: AUTH_SESSION_TTL_MS / 1000, path: '/', }); // Broadcast auth notification — desktop sees who authenticated ctx.broadcast(SseEvent.TunnelQrAuthUsed, { ip: clientIp, ua: clientUA, timestamp: Date.now(), }); return reply.redirect('/'); }); // ========== QR Regeneration ========== app.post('/api/tunnel/qr/regenerate', async () => { ctx.tunnelManager.regenerateQrToken(); return { success: true }; }); // ========== Auth Session Revocation ========== app.post('/api/auth/revoke', async (req) => { const result = RevokeSessionSchema.safeParse(req.body); if (result.success && result.data.sessionToken) { ctx.authSessions?.delete(result.data.sessionToken); } else { // Revoke all sessions (nuclear option) ctx.authSessions?.clear(); } return { success: true }; }); // ═══════════════════════════════════════════════════════════════ // CLI Integrations (OpenCode) // ═══════════════════════════════════════════════════════════════ // ========== OpenCode ========== app.get('/api/opencode/status', async () => { const { isOpenCodeAvailable, resolveOpenCodeDir } = await import('../../utils/opencode-cli-resolver.js'); return { available: isOpenCodeAvailable(), path: resolveOpenCodeDir(), }; }); // ═══════════════════════════════════════════════════════════════ // State & Lifecycle (cleanup, lifecycle log, stats) // ═══════════════════════════════════════════════════════════════ // ========== State & Lifecycle ========== app.post('/api/cleanup-state', async () => { const activeSessionIds = new Set(ctx.sessions.keys()); const result = ctx.store.cleanupStaleSessions(activeSessionIds); const lifecycleLog = getLifecycleLog(); for (const s of result.cleaned) { lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name }); } return { success: true, cleanedSessions: result.count }; }); app.get('/api/session-lifecycle', async (req) => { const query = req.query as { sessionId?: string; event?: string; since?: string; limit?: string; }; const lifecycleLog = getLifecycleLog(); const entries = await lifecycleLog.query({ sessionId: query.sessionId, event: query.event as import('../../types.js').LifecycleEventType, since: query.since ? Number(query.since) : undefined, limit: query.limit ? Math.min(Number(query.limit), 1000) : 200, }); return { success: true, entries }; }); // ========== Stats ========== function collectActiveTokens(): Record { const tokens: Record = {}; for (const [sessionId, session] of ctx.sessions) { tokens[sessionId] = { inputTokens: session.inputTokens, outputTokens: session.outputTokens, totalCost: session.totalCost, }; } return tokens; } app.get('/api/stats', async () => { const activeSessionTokens = collectActiveTokens(); return { success: true, stats: ctx.store.getAggregateStats(activeSessionTokens), raw: ctx.store.getGlobalStats(), }; }); app.get('/api/token-stats', async () => { const activeSessionTokens = collectActiveTokens(); return { success: true, daily: ctx.store.getDailyStats(30), totals: ctx.store.getAggregateStats(activeSessionTokens), }; }); // ═══════════════════════════════════════════════════════════════ // Configuration & Settings (config, settings, model config, CPU priority) // ═══════════════════════════════════════════════════════════════ // ========== Config ========== app.get('/api/config', async () => { return { success: true, config: ctx.store.getConfig() }; }); app.put('/api/config', async (req) => { const configData = parseBody(ConfigUpdateSchema, req.body, 'Invalid config'); ctx.store.setConfig(configData as Partial>); return { success: true, config: ctx.store.getConfig() }; }); // ========== Debug/Memory ========== app.get('/api/debug/memory', async () => { const mem = process.memoryUsage(); const subagentStats = subagentWatcher.getStats(); const serverMapSizes = { sessions: ctx.sessions.size, runSummaryTrackers: ctx.runSummaryTrackers.size, scheduledRuns: ctx.scheduledRuns.size, activePlanOrchestrators: ctx.activePlanOrchestrators.size, }; const totalServerMapEntries = Object.values(serverMapSizes).reduce((a, b) => a + b, 0); const totalSubagentMapEntries = Object.values(subagentStats).reduce((a, b) => a + b, 0); return { memory: { rss: mem.rss, rssMB: Math.round((mem.rss / 1024 / 1024) * 10) / 10, heapUsed: mem.heapUsed, heapUsedMB: Math.round((mem.heapUsed / 1024 / 1024) * 10) / 10, heapTotal: mem.heapTotal, heapTotalMB: Math.round((mem.heapTotal / 1024 / 1024) * 10) / 10, external: mem.external, externalMB: Math.round((mem.external / 1024 / 1024) * 10) / 10, arrayBuffers: mem.arrayBuffers, arrayBuffersMB: Math.round((mem.arrayBuffers / 1024 / 1024) * 10) / 10, }, mapSizes: { server: serverMapSizes, subagentWatcher: subagentStats, totals: { serverEntries: totalServerMapEntries, subagentEntries: totalSubagentMapEntries, allEntries: totalServerMapEntries + totalSubagentMapEntries, }, }, watchers: { fileDebouncers: subagentStats.fileDebouncerCount, dirWatchers: subagentStats.dirWatcherCount, total: subagentStats.fileDebouncerCount + subagentStats.dirWatcherCount, }, timers: { subagentIdleTimers: subagentStats.idleTimerCount, total: subagentStats.idleTimerCount, }, uptime: { seconds: Math.round(process.uptime()), formatted: formatUptime(process.uptime()), }, timestamp: Date.now(), }; }); // ========== System Stats ========== app.get('/api/system/stats', async () => { return getSystemStats(); }); // ========== Settings ========== app.get('/api/settings', async () => { return readJsonConfig(SETTINGS_PATH, 'settings', {}); }); app.put('/api/settings', async (req) => { const settings = parseBody(SettingsUpdateSchema, req.body, 'Invalid settings') as Record; try { const dir = dirname(SETTINGS_PATH); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } let existing: Record = {}; try { existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8')); } catch { /* ignore */ } const merged = { ...existing, ...settings }; await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2)); // Handle subagent tracking toggle dynamically toggleService((settings.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher'); // Handle image watcher toggle dynamically toggleService((settings.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => { // Re-watch all active sessions that have image watcher enabled for (const session of ctx.sessions.values()) { if (session.imageWatcherEnabled) { imageWatcher.watchSession(session.id, session.workingDir); } } }); // Handle tunnel toggle dynamically if ('tunnelEnabled' in settings) { const tunnelEnabled = settings.tunnelEnabled as boolean; if (tunnelEnabled && !ctx.tunnelManager.isRunning()) { ctx.tunnelManager.start(ctx.port, ctx.https); console.log('Tunnel started via settings change'); } else if (tunnelEnabled && ctx.tunnelManager.isRunning() && ctx.tunnelManager.getUrl()) { // Tunnel already running — re-emit so the client gets the URL ctx.broadcast(SseEvent.TunnelStarted, { url: ctx.tunnelManager.getUrl() }); console.log('Tunnel already running, re-broadcast URL to client'); } else if (!tunnelEnabled && ctx.tunnelManager.isRunning()) { ctx.tunnelManager.stop(); console.log('Tunnel stopped via settings change'); } } return { success: true }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } }); // ========== Model Configuration ========== app.get('/api/execution/model-config', async () => { const settings = await readJsonConfig>(SETTINGS_PATH, 'model config', {}); return { success: true, data: settings.modelConfig || {} }; }); app.put('/api/execution/model-config', async (req) => { const modelConfig = parseBody(ModelConfigUpdateSchema, req.body, 'Invalid model config') as Record; try { let existingSettings: Record = {}; try { const content = await fs.readFile(SETTINGS_PATH, 'utf-8'); existingSettings = JSON.parse(content); } catch { // File doesn't exist yet, start fresh } existingSettings.modelConfig = modelConfig; const dir = dirname(SETTINGS_PATH); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } await fs.writeFile(SETTINGS_PATH, JSON.stringify(existingSettings, null, 2)); return { success: true }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } }); // ========== CPU Priority ========== app.get('/api/sessions/:id/cpu-limit', async (req) => { const { id } = req.params as { id: string }; const session = findSessionOrFail(ctx, id); return { success: true, nice: session.niceConfig, }; }); app.post('/api/sessions/:id/cpu-limit', async (req) => { const { id } = req.params as { id: string }; const session = findSessionOrFail(ctx, id); const body = parseBody(CpuLimitSchema, req.body, 'Invalid request body') as Partial; session.setNice(body); ctx.persistSessionState(session); ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) }); return { success: true, nice: session.niceConfig, note: 'Nice priority only affects newly created mux sessions, not currently running ones.', }; }); // ═══════════════════════════════════════════════════════════════ // Subagent Management (window states, parents, monitoring, transcripts) // ═══════════════════════════════════════════════════════════════ // ========== Subagent Window State Persistence ========== app.get('/api/subagent-window-states', async () => { return readJsonConfig(windowStatesPath, 'subagent window states', { minimized: {}, open: [] }); }); app.put('/api/subagent-window-states', async (req) => { const states = parseBody(SubagentWindowStatesSchema, req.body, 'Invalid window states') as Record; try { const dir = dirname(windowStatesPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } await fs.writeFile(windowStatesPath, JSON.stringify(states, null, 2)); return { success: true }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } }); // ========== Subagent Parent Associations ========== app.get('/api/subagent-parents', async () => { return readJsonConfig(parentMapPath, 'subagent parent map', {}); }); app.put('/api/subagent-parents', async (req) => { const parentMap = parseBody(SubagentParentMapSchema, req.body, 'Invalid parent map'); try { const dir = dirname(parentMapPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } await fs.writeFile(parentMapPath, JSON.stringify(parentMap, null, 2)); return { success: true }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } }); // ========== Subagent Monitoring ========== app.get('/api/subagents', async (req) => { const { minutes } = req.query as { minutes?: string }; const subagents = minutes ? subagentWatcher.getRecentSubagents(parseInt(minutes, 10)) : subagentWatcher.getSubagents(); return { success: true, data: subagents }; }); app.get('/api/sessions/:id/subagents', async (req) => { const { id } = req.params as { id: string }; const session = findSessionOrFail(ctx, id); const subagents = subagentWatcher.getSubagentsForSession(session.workingDir); return { success: true, data: subagents }; }); app.get('/api/subagents/:agentId', async (req) => { const { agentId } = req.params as { agentId: string }; const info = subagentWatcher.getSubagent(agentId); if (!info) { return createErrorResponse(ApiErrorCode.NOT_FOUND, `Subagent ${agentId} not found`); } return { success: true, data: info }; }); app.get('/api/subagents/:agentId/transcript', async (req) => { const { agentId } = req.params as { agentId: string }; const { limit, format } = req.query as { limit?: string; format?: 'raw' | 'formatted' }; const limitNum = limit ? parseInt(limit, 10) : undefined; const transcript = await subagentWatcher.getTranscript(agentId, limitNum); if (format === 'formatted') { const formatted = subagentWatcher.formatTranscript(transcript); return { success: true, data: { formatted, entryCount: transcript.length } }; } return { success: true, data: transcript }; }); app.delete('/api/subagents/:agentId', async (req) => { const { agentId } = req.params as { agentId: string }; const info = subagentWatcher.getSubagent(agentId); if (!info) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subagent not found'); } const killed = await subagentWatcher.killSubagent(agentId); if (killed) { return { success: true, data: { agentId, status: 'killed' } }; } return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Subagent not found or already completed'); }); app.post('/api/subagents/cleanup', async () => { const removed = subagentWatcher.cleanupNow(); return { success: true, data: { removed, remaining: subagentWatcher.getSubagents().length } }; }); app.delete('/api/subagents', async () => { const cleared = subagentWatcher.clearAll(); return { success: true, data: { cleared } }; }); // ═══════════════════════════════════════════════════════════════ // Screenshots (upload, list, serve) // ═══════════════════════════════════════════════════════════════ // ========== Screenshots ========== app.post('/api/screenshots', async (req, reply) => { const contentType = req.headers['content-type'] ?? ''; if (!contentType.includes('multipart/form-data')) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data'); } // Parse multipart boundary const boundaryMatch = contentType.match(/boundary=(.+?)(?:;|$)/); if (!boundaryMatch) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing boundary'); } // Collect raw body const chunks: Buffer[] = []; let totalSize = 0; for await (const chunk of req.raw) { totalSize += chunk.length; if (totalSize > MAX_SCREENSHOT_SIZE) { reply.code(413); return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'File too large (max 10MB)'); } chunks.push(chunk as Buffer); } const body = Buffer.concat(chunks); // Extract file from multipart body const boundary = '--' + boundaryMatch[1]; const boundaryBuf = Buffer.from(boundary); const parts: { headers: string; data: Buffer }[] = []; let pos = 0; // Find each part between boundaries while (pos < body.length) { const start = body.indexOf(boundaryBuf, pos); if (start === -1) break; const afterBoundary = start + boundaryBuf.length; // Check for closing boundary (--) if (body[afterBoundary] === 0x2d && body[afterBoundary + 1] === 0x2d) break; // Skip \r\n after boundary const headerStart = afterBoundary + 2; const headerEnd = body.indexOf(Buffer.from('\r\n\r\n'), headerStart); if (headerEnd === -1) break; const headers = body.subarray(headerStart, headerEnd).toString(); const dataStart = headerEnd + 4; const nextBoundary = body.indexOf(boundaryBuf, dataStart); // Data ends 2 bytes before next boundary (\r\n) const dataEnd = nextBoundary === -1 ? body.length : nextBoundary - 2; parts.push({ headers, data: body.subarray(dataStart, dataEnd) }); pos = nextBoundary === -1 ? body.length : nextBoundary; } const filePart = parts.find((p) => p.headers.includes('name="file"')); if (!filePart || filePart.data.length === 0) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No file uploaded'); } // Determine extension from Content-Type or filename let ext = '.png'; const filenameMatch = filePart.headers.match(/filename="(.+?)"/); if (filenameMatch) { const origExt = filenameMatch[1].match(/\.(png|jpg|jpeg|webp|gif)$/i); if (origExt) ext = origExt[0].toLowerCase(); } const ctMatch = filePart.headers.match(/Content-Type:\s*image\/(png|jpeg|webp|gif)/i); if (ctMatch) { const map: Record = { png: '.png', jpeg: '.jpg', webp: '.webp', gif: '.gif', }; ext = map[ctMatch[1].toLowerCase()] ?? ext; } // Save to ~/.codeman/screenshots/ if (!existsSync(SCREENSHOTS_DIR)) { mkdirSync(SCREENSHOTS_DIR, { recursive: true }); } const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19); const filename = `screenshot_${timestamp}${ext}`; const filepath = join(SCREENSHOTS_DIR, filename); await fs.writeFile(filepath, filePart.data); return { success: true, path: filepath, filename }; }); app.get('/api/screenshots', async () => { if (!existsSync(SCREENSHOTS_DIR)) { return { files: [] }; } const files = readdirSync(SCREENSHOTS_DIR) .filter((f) => /\.(png|jpg|jpeg|webp|gif)$/i.test(f)) .sort() .reverse() .slice(0, 50) .map((name) => ({ name, path: join(SCREENSHOTS_DIR, name) })); return { files }; }); app.get('/api/screenshots/:name', async (req, reply) => { const { name } = req.params as { name: string }; // Prevent path traversal if (name.includes('/') || name.includes('\\') || name.includes('..')) { reply.code(400); return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid filename'); } const filepath = join(SCREENSHOTS_DIR, name); if (!existsSync(filepath)) { reply.code(404); return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Screenshot not found'); } const ext = name.match(/\.(png|jpg|jpeg|webp|gif)$/i)?.[1]?.toLowerCase() ?? 'png'; const mimeMap: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', }; reply.type(mimeMap[ext] ?? 'image/png'); return fs.readFile(filepath); }); }