Files
Codeman/src/web/routes/system-routes.ts
T
2026-06-19 17:58:06 -04:00

994 lines
39 KiB
TypeScript

/**
* @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 { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
import fs from 'node:fs/promises';
import { totalmem, freemem, loadavg, cpus } from 'node:os';
import { execSync, spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { dataPath } from '../../config/instance.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.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 { workflowRunWatcher } from '../../workflow-run-watcher.js';
import { applyStatusLineConfig } from '../../hooks-config.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
import {
buildAwayDigest,
resolveAwayDigestRange,
type AwayDigestSession,
type AwayDigestSubagent,
} from '../away-digest.js';
import {
findSessionOrFail,
formatUptime,
parseBody,
readJsonConfig,
toggleService,
SETTINGS_PATH,
} from '../route-helpers.js';
import { SseEvent } from '../sse-events.js';
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.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 = dataPath('screenshots');
/** Cached CPU count — doesn't change at runtime */
const CPU_COUNT = cpus().length;
function parseOptionalNumber(value: string | undefined): number | undefined {
if (value === undefined || value.trim() === '') return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : Number.NaN;
}
/** 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 },
};
}
}
/**
* Build the URL the spanning browser window should open, pinned to localhost.
* Takes only a digits-only port from the (untrusted) Host header so nothing
* attacker-controllable reaches the launched browser; falls back to the default
* port when the header is absent/odd. Exported for unit testing.
*/
export function resolveSpanUrl(hostHeader: string | undefined, fallbackPort = '3000'): string {
const hostPort = String(hostHeader ?? '').split(':')[1] ?? '';
const port = /^\d+$/.test(hostPort) ? hostPort : fallbackPort;
return `http://localhost:${port}`;
}
export function registerSystemRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
): void {
const windowStatesPath = dataPath('subagent-window-states.json');
const parentMapPath = dataPath('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 {};
});
// ========== 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 {};
});
// ═══════════════════════════════════════════════════════════════
// Multi-monitor: span Codeman across all displays
// ═══════════════════════════════════════════════════════════════
// Spawn scripts/span-codeman.sh, which opens a fresh, maximized browser --app
// window sized to the union of all displays — so in-page floating session
// panels can be dragged across the physical monitor seam. macOS only; needs
// the one-time "Displays have separate Spaces" OFF prerequisite (see script).
app.post('/api/system/span-displays', async (req, reply) => {
// macOS only: the launcher uses osascript + Finder desktop bounds and Chrome
// --app geometry flags. Fail clearly elsewhere instead of spawning a bash
// that errors out invisibly (the toast would otherwise lie "Opening…").
if (process.platform !== 'darwin') {
return reply
.code(400)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Multi-monitor spanning runs on the Codeman server, which is not macOS. ' +
'If your monitors are on a remote Mac, run scripts/span-codeman.sh locally on that Mac with this server URL — see the script header for details.'
)
);
}
// Resolve the bundled launcher relative to this module (works from src/ and dist/).
const scriptPath = join(dirname(fileURLToPath(import.meta.url)), '../../../scripts/span-codeman.sh');
if (!existsSync(scriptPath)) {
return reply.code(500).send(createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'span-codeman.sh not found'));
}
// Point the spanning window at THIS server (localhost + sanitized port).
const url = resolveSpanUrl(req.headers.host);
try {
const child = spawn('bash', [scriptPath, url], { detached: true, stdio: 'ignore' });
child.on('error', (err) => app.log.error({ err }, 'span-displays launch failed'));
child.unref();
return { url };
} catch (err) {
return reply.code(500).send(createErrorResponse(ApiErrorCode.INTERNAL_ERROR, getErrorMessage(err)));
}
});
// ═══════════════════════════════════════════════════════════════
// Self-Update (App Settings → Updates)
// ═══════════════════════════════════════════════════════════════
// Install info + whether a newer release exists. Manual, user-triggered.
app.get('/api/system/update/check', async () => {
const check = await checkForUpdate();
const info = getInstallInfo();
return { ...info, ...check };
});
// Poll target for update progress — survives the restart the update triggers.
app.get('/api/system/update/status', async () => getUpdateStatusForApi());
// Kick off a detached update to the latest release. Returns immediately; the
// browser then polls /api/system/update/status across the service restart.
app.post('/api/system/update', async (_req, reply) => {
const result = await startUpdate();
if (result.ok) {
return { updateId: result.updateId, toTag: result.toTag, toVersion: result.toVersion };
}
const map = {
'in-flight': { http: 409, api: ApiErrorCode.ALREADY_EXISTS },
'up-to-date': { http: 409, api: ApiErrorCode.ALREADY_EXISTS },
'not-git': { http: 400, api: ApiErrorCode.INVALID_INPUT },
disabled: { http: 403, api: ApiErrorCode.INVALID_INPUT },
'bad-tag': { http: 400, api: ApiErrorCode.INVALID_INPUT },
error: { http: 500, api: ApiErrorCode.INTERNAL_ERROR },
} as const;
const m = map[result.code];
return reply.code(m.http).send(createErrorResponse(m.api, result.message));
});
// ═══════════════════════════════════════════════════════════════
// 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(),
};
});
app.get('/api/codex/status', async () => {
const { isCodexAvailable, resolveCodexDir } = await import('../../utils/codex-cli-resolver.js');
return {
available: isCodexAvailable(),
path: resolveCodexDir(),
};
});
// ═══════════════════════════════════════════════════════════════
// 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 { 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 { entries };
});
// ========== Stats ==========
function collectActiveTokens(): Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> {
const tokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
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 {
stats: ctx.store.getAggregateStats(activeSessionTokens),
raw: ctx.store.getGlobalStats(),
};
});
app.get('/api/token-stats', async () => {
const activeSessionTokens = collectActiveTokens();
return {
daily: ctx.store.getDailyStats(30),
totals: ctx.store.getAggregateStats(activeSessionTokens),
};
});
app.get('/api/away-digest', async (req, reply) => {
const query = req.query as {
range?: string;
since?: string;
until?: string;
lastViewed?: string;
};
let range;
try {
range = resolveAwayDigestRange({
range: query.range,
since: parseOptionalNumber(query.since),
until: parseOptionalNumber(query.until),
lastViewed: parseOptionalNumber(query.lastViewed),
});
} catch (err) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
}
const lifecycleLog = getLifecycleLog();
const lifecycleEntries = await lifecycleLog.query({
since: range.since,
limit: 1000,
});
const sessions: AwayDigestSession[] = Array.from(ctx.sessions.values()).map((session) => ({
id: session.id,
name: session.name,
status: session.status,
inputTokens: session.inputTokens,
outputTokens: session.outputTokens,
totalCost: session.totalCost,
}));
const runSummaries = Array.from(ctx.runSummaryTrackers.values()).map((tracker) => tracker.getSummary());
const digest = buildAwayDigest({
range,
lifecycleEntries,
runSummaries,
sessions,
dailyTokenStats: ctx.store.getDailyStats(30),
subagents: subagentWatcher.getRecentSubagents(60) as AwayDigestSubagent[],
now: range.until,
});
return { success: true, digest };
});
// ═══════════════════════════════════════════════════════════════
// Configuration & Settings (config, settings, model config, CPU priority)
// ═══════════════════════════════════════════════════════════════
// ========== Config ==========
app.get('/api/config', async () => {
return { config: ctx.store.getConfig() };
});
app.put('/api/config', async (req) => {
const configData = parseBody(ConfigUpdateSchema, req.body, 'Invalid config');
ctx.store.setConfig(configData as Partial<ReturnType<typeof ctx.store.getConfig>>);
return { 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<string, unknown>;
// COD-55: enabling the Cloudflare tunnel publishes the whole app (full terminal
// control = effectively RCE) to a public *.trycloudflare.com URL. Because the
// tunnel binds to loopback, server.ts's non-loopback bind guard never trips, and
// with no CODEMAN_PASSWORD the auth middleware is inactive — so the tunnel URL is
// unauthenticated. Refuse to start a tunnel unless auth is configured OR exposure
// is acknowledged: either the CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var, or an
// explicit per-request `acknowledgeUnauthTunnel:true` (the UI sends this after a
// confirm dialog). This keeps curl/API/CLI callers protected by default while
// letting an operator opt in from the browser without setting the env var.
// Guard runs BEFORE persisting so a refused tunnelEnabled:true is not saved.
if (settings.tunnelEnabled === true && !ctx.tunnelManager.isRunning()) {
const acknowledged = isUnauthenticatedNetworkAcknowledged() || settings.acknowledgeUnauthTunnel === true;
if (!acknowledged) {
const msg =
'Refusing to start the Cloudflare tunnel without authentication: it would publish ' +
'full terminal control to a public URL with no password. Set CODEMAN_PASSWORD to ' +
'require login, set CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1, or resend with ' +
'acknowledgeUnauthTunnel:true to acknowledge an unauthenticated public tunnel.';
throw Object.assign(new Error(msg), {
statusCode: 403,
body: createErrorResponse(ApiErrorCode.OPERATION_FAILED, msg),
});
}
// Loud warning whenever a public tunnel is started with no password — whether
// acknowledged via env var or the per-request UI confirmation.
if (!process.env.CODEMAN_PASSWORD) {
console.warn(
'⚠️ [tunnel] Starting an UNAUTHENTICATED public Cloudflare tunnel — no CODEMAN_PASSWORD set. ' +
'Anyone with the tunnel URL gets full terminal control (effectively RCE). ' +
'Set CODEMAN_PASSWORD to require login.'
);
}
}
try {
const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
let existing: Record<string, unknown> = {};
try {
existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
} catch {
/* ignore */
}
// statusLineTelemetry and acknowledgeUnauthTunnel are ACTION fields (not stored
// settings) — strip them before persisting so settings.json stays clean.
const { statusLineTelemetry, acknowledgeUnauthTunnel, ...settingsToStore } = settings;
const merged = { ...existing, ...settingsToStore };
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 ultracode/workflow run watcher toggle dynamically (default OFF).
// Either the docked panel OR the floating windows keep the watcher running.
toggleService(
((settings.showUltracodeAgents as boolean) ?? false) ||
((settings.ultracodeFloatingWindows as boolean) ?? false),
workflowRunWatcher,
'Workflow run 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);
}
}
});
// Plan-usage chip: its DISPLAY is per-device (client-side, see settings-ui.js).
// Telemetry COLLECTION is server-side and enable-sticky — when a client turns
// the chip ON it sends statusLineTelemetry:true and we (re)inject our exporter
// into every ACTIVE Claude session's working dir so the live % starts flowing
// immediately (no new session needed). We deliberately never auto-REMOVE here:
// the exporter is benign/print-through and a per-repo settings.local.json is
// shared by sibling sessions, so one device's "off" must not yank the exporter
// another device's chip depends on. Each dir handled once.
if (statusLineTelemetry === true) {
const dirs = new Set<string>();
for (const session of ctx.sessions.values()) {
if (session.mode === 'claude' && session.workingDir) dirs.add(session.workingDir);
}
await Promise.all([...dirs].map((dir) => applyStatusLineConfig(dir, true).catch(() => {})));
}
// 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 {};
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
});
// ========== Model Configuration ==========
app.get('/api/execution/model-config', async () => {
const settings = await readJsonConfig<Record<string, unknown>>(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<string, unknown>;
try {
let existingSettings: Record<string, unknown> = {};
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 {};
} 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 {
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<NiceConfig>;
session.setNice(body);
ctx.persistSessionState(session);
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
return {
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<string, unknown>;
try {
const dir = dirname(windowStatesPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
await fs.writeFile(windowStatesPath, JSON.stringify(states, null, 2));
return {};
} 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 {};
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
});
// ========== Workflow Run Monitoring (ultracode) ==========
// LEFT-pane list: lightweight run summaries (no agents[]).
app.get('/api/workflows', async (req) => {
const { minutes } = req.query as { minutes?: string };
const runs = minutes
? workflowRunWatcher.getRecentRunSummaries(parseInt(minutes, 10))
: workflowRunWatcher.getAllRunSummaries();
return { success: true, data: runs };
});
// RIGHT-pane detail: full run incl. agents[] (tokens/toolCalls/state per agent).
app.get('/api/workflows/:runId', async (req) => {
const { runId } = req.params as { runId: string };
const run = workflowRunWatcher.getRun(runId);
if (!run) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Workflow run ${runId} not found`);
}
return { success: true, data: run };
});
// ========== 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<string, string> = {
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 { 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<string, string> = {
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);
});
}