mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 23:49:41 +02:00
* refactor: extract SSE event handlers into named class methods Replace ~80 inline addListener closures in connectSSE() with a declarative _SSE_HANDLER_MAP array that drives registration in a single loop. Each handler is now a named _on* method on CodemanApp, making them individually addressable for LLM navigation. Add SSE_EVENTS constant object in constants.js to eliminate magic event-type strings scattered across the frontend. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: fix inaccuracies in CLAUDE.md - Fix types barrel path: src/types.ts → src/types/index.ts - Update app.js line count: ~12K → ~11.5K - Correct route handler counts (113 → 111, per-group fixes) - Add code style, ESM gotcha, env vars, route test, lifecycle log docs - Add Node 22 CI note, test teardown timeout, port range Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add mobile screenshots and QR auth security writeup to README Add 3 mobile screenshots (landing, idle, active) and expand the mobile section with QR auth security design details and a touch-optimized interface subsection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: bundle xterm-zerolag-input as vendor IIFE and add pre-commit hook Build and postinstall now bundle the local xterm-zerolag-input package as an IIFE at vendor/xterm-zerolag-input.js with global LocalEchoOverlay shim. Add git pre-commit hook that runs prettier --check on staged .ts files to catch format issues before CI. Also bump constants.js and app.js cache-bust versions to 0.3.0 and add tunnel upload URL display row in settings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add cloudflared install support and interactive launch menu - Add optional cloudflared dependency detection and installation across 6 distro families (macOS, Debian, Fedora, Arch, Alpine, SUSE) - Add tunnel systemd service setup helper - Replace post-install instructions with interactive launch menu (run now / systemd service / skip) - Uninstall now cleans up both codeman-web and codeman-tunnel services Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: gitignore readme-preview.mjs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: WIP — SSE event constants, @fileoverview docs, CLAUDE.md compression - Migrate broadcast() string literals → SseEvent.* typed constants - Add @fileoverview with cross-domain references to all 13 type domain files - Add @fileoverview to frontend JS modules (constants, mobile, voice, etc.) - Add section dividers to route files for LLM scanability - Compress CLAUDE.md: flat file list → domain table, fix counts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: optimize codebase for LLM context window efficiency CLAUDE.md: 456 → 309 lines (32% reduction) - Merge Commands into compact table, remove redundant bash block - Convert Security section to dense table format - Merge Performance + Resource Limits, Debugging + Troubleshooting - Compress Tunnel, Memory Leak, Scripts, Screenshots sections - Remove Key Patterns that duplicate @fileoverview in source files Backend @fileoverview enhancements (10 priority files): - session.ts: key methods, events, cross-domain refs - respawn-controller.ts: state machine, idle detection layers - ralph-tracker.ts: exports, circuit breaker, events - ralph-loop.ts: lifecycle, persistence, events - subagent-watcher.ts: watched patterns, teammate detection - server.ts: coordination list, port interfaces - state-store.ts: dual-file persistence, migration - session-manager.ts: lifecycle methods, mutex guard - hooks-config.ts: hook events list, categories - sse-events.ts: category breakdown (~90 events, 17 categories) Frontend app.js: add 6 section dividers, update @fileoverview line refs Fix: escape glob `*/` in JSDoc that broke ESLint parser Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR #29 review bugs - server.ts: replace hardcoded 'session:needsRefresh' with SseEvent constant - install.sh: fix Alpine cloudflared install for non-root (download to tmpfile first) - install.sh: replace Arch pacman (AUR-only) with direct binary download - index.html: bump all 8 remaining cache-bust versions from v0.2.9 to v0.3.0 - mobile-handlers.js: fix @dependency annotation (keyboard-accessory.js, not constants.js) - types/push.ts: fix layer number (4, not 5) - subagent-watcher.ts: fix watched pattern path to include {session} segment - constants.js: fix SSE_EVENTS count in @fileoverview (~73, not ~65) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
800 lines
30 KiB
TypeScript
800 lines
30 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 { 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, 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/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 ==========
|
|
|
|
app.get('/api/stats', async () => {
|
|
const activeSessionTokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
|
|
for (const [sessionId, session] of ctx.sessions) {
|
|
activeSessionTokens[sessionId] = {
|
|
inputTokens: session.inputTokens,
|
|
outputTokens: session.outputTokens,
|
|
totalCost: session.totalCost,
|
|
};
|
|
}
|
|
return {
|
|
success: true,
|
|
stats: ctx.store.getAggregateStats(activeSessionTokens),
|
|
raw: ctx.store.getGlobalStats(),
|
|
};
|
|
});
|
|
|
|
app.get('/api/token-stats', async () => {
|
|
const activeSessionTokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
|
|
for (const [sessionId, session] of ctx.sessions) {
|
|
activeSessionTokens[sessionId] = {
|
|
inputTokens: session.inputTokens,
|
|
outputTokens: session.outputTokens,
|
|
totalCost: session.totalCost,
|
|
};
|
|
}
|
|
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 parseResult = ConfigUpdateSchema.safeParse(req.body);
|
|
if (!parseResult.success) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid config: ${parseResult.error.message}`);
|
|
}
|
|
ctx.store.setConfig(parseResult.data as Partial<ReturnType<typeof ctx.store.getConfig>>);
|
|
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 () => {
|
|
try {
|
|
const content = await fs.readFile(SETTINGS_PATH, 'utf-8');
|
|
return JSON.parse(content);
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
console.error('Failed to read settings:', err);
|
|
}
|
|
}
|
|
return {};
|
|
});
|
|
|
|
app.put('/api/settings', async (req) => {
|
|
const settingsResult = SettingsUpdateSchema.safeParse(req.body);
|
|
if (!settingsResult.success) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid settings');
|
|
}
|
|
const settings = settingsResult.data as Record<string, unknown>;
|
|
|
|
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 */
|
|
}
|
|
const merged = { ...existing, ...settings };
|
|
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
|
|
|
|
// Handle subagent tracking toggle dynamically
|
|
const subagentEnabled = settings.subagentTrackingEnabled ?? true;
|
|
if (subagentEnabled && !subagentWatcher.isRunning()) {
|
|
subagentWatcher.start();
|
|
console.log('Subagent watcher started via settings change');
|
|
} else if (!subagentEnabled && subagentWatcher.isRunning()) {
|
|
subagentWatcher.stop();
|
|
console.log('Subagent watcher stopped via settings change');
|
|
}
|
|
|
|
// Handle image watcher toggle dynamically
|
|
const imageWatcherEnabled = settings.imageWatcherEnabled ?? false;
|
|
if (imageWatcherEnabled && !imageWatcher.isRunning()) {
|
|
imageWatcher.start();
|
|
// 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);
|
|
}
|
|
}
|
|
console.log('Image watcher started via settings change');
|
|
} else if (!imageWatcherEnabled && imageWatcher.isRunning()) {
|
|
imageWatcher.stop();
|
|
console.log('Image watcher stopped via settings change');
|
|
}
|
|
|
|
// 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 () => {
|
|
try {
|
|
const content = await fs.readFile(SETTINGS_PATH, 'utf-8');
|
|
const settings = JSON.parse(content);
|
|
return { success: true, data: settings.modelConfig || {} };
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
console.error('Failed to read model config:', err);
|
|
}
|
|
return { success: true, data: {} };
|
|
}
|
|
});
|
|
|
|
app.put('/api/execution/model-config', async (req) => {
|
|
const mcResult = ModelConfigUpdateSchema.safeParse(req.body);
|
|
if (!mcResult.success) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid model config');
|
|
}
|
|
const modelConfig = mcResult.data 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 { 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 clResult = CpuLimitSchema.safeParse(req.body);
|
|
if (!clResult.success) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
}
|
|
const body = clResult.data as Partial<NiceConfig>;
|
|
|
|
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 () => {
|
|
try {
|
|
const content = await fs.readFile(windowStatesPath, 'utf-8');
|
|
return JSON.parse(content);
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
console.error('Failed to read subagent window states:', err);
|
|
}
|
|
}
|
|
return { minimized: {}, open: [] };
|
|
});
|
|
|
|
app.put('/api/subagent-window-states', async (req) => {
|
|
const swResult = SubagentWindowStatesSchema.safeParse(req.body);
|
|
if (!swResult.success) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid window states');
|
|
}
|
|
const states = swResult.data 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 { success: true };
|
|
} catch (err) {
|
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
|
}
|
|
});
|
|
|
|
// ========== Subagent Parent Associations ==========
|
|
|
|
app.get('/api/subagent-parents', async () => {
|
|
try {
|
|
const content = await fs.readFile(parentMapPath, 'utf-8');
|
|
return JSON.parse(content);
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
console.error('Failed to read subagent parent map:', err);
|
|
}
|
|
}
|
|
return {};
|
|
});
|
|
|
|
app.put('/api/subagent-parents', async (req) => {
|
|
const spResult = SubagentParentMapSchema.safeParse(req.body);
|
|
if (!spResult.success) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid parent map');
|
|
}
|
|
const parentMap = spResult.data;
|
|
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.status(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 { 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.status(400);
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid filename');
|
|
}
|
|
const filepath = join(SCREENSHOTS_DIR, name);
|
|
if (!existsSync(filepath)) {
|
|
reply.status(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);
|
|
});
|
|
}
|