diff --git a/docs/phase3-implementation-plan.md b/docs/phase3-implementation-plan.md index 30628352..2931a50c 100644 --- a/docs/phase3-implementation-plan.md +++ b/docs/phase3-implementation-plan.md @@ -209,6 +209,30 @@ export interface RouteContext { /** Batch a task update for SSE broadcasting */ batchTaskUpdate(sessionId: string, task: unknown): void; + + // === Config Getters (used by session create, quick-start, ralph-loop, plan routes) === + + /** Get global nice/ionice config from settings */ + getGlobalNiceConfig(): Promise; + + /** Get model config (defaultModel, etc.) from settings */ + getModelConfig(): Promise<{ defaultModel?: string } | undefined>; + + /** Get Claude mode config (claudeMode, allowedTools) from settings */ + getClaudeModeConfig(): Promise<{ claudeMode?: string; allowedTools?: string[] }>; + + // === Scheduling (used by scheduled-routes) === + + /** Start a scheduled run (creates session, runs prompt, manages lifecycle) */ + startScheduledRun(prompt: string, workingDir: string, durationMinutes: number): Promise; + + /** Stop a scheduled run by ID */ + stopScheduledRun(id: string): Promise; + + // === Notifications (used by hook-event route) === + + /** Send push notifications to all subscribed clients */ + sendPushNotifications(event: string, data: Record): void; } /** @@ -257,20 +281,26 @@ private createRouteContext(): RouteContext { batchTerminalData: this.batchTerminalData.bind(this), broadcastSessionStateDebounced: this.broadcastSessionStateDebounced.bind(this), batchTaskUpdate: this.batchTaskUpdate.bind(this), + getGlobalNiceConfig: this.getGlobalNiceConfig.bind(this), + getModelConfig: this.getModelConfig.bind(this), + getClaudeModeConfig: this.getClaudeModeConfig.bind(this), + startScheduledRun: this.startScheduledRun.bind(this), + stopScheduledRun: this.stopScheduledRun.bind(this), + sendPushNotifications: this.sendPushNotifications.bind(this), }; } ``` ### Helper: findSessionOrFail -Add a shared helper to `route-context.ts` (replaces 189 repetitions of the session-not-found pattern): +Add a shared helper to `route-context.ts` (replaces ~43 repetitions of the session-not-found pattern): ```typescript import { createErrorResponse, ApiErrorCode } from '../types.js'; /** * Look up a session by ID, throwing a structured error if not found. - * Route handlers call this to avoid 189 repetitions of the NOT_FOUND pattern. + * Route handlers call this to avoid ~43 repetitions of the NOT_FOUND pattern. */ export function findSessionOrFail(ctx: RouteContext, sessionId: string): Session { const session = ctx.sessions.get(sessionId); @@ -300,9 +330,26 @@ this.app.setErrorHandler((error, _req, reply) => { }); ``` +### Error Pattern Convention + +**Use `findSessionOrFail` (throw) consistently for session lookups.** For other not-found patterns (scheduled runs, subagents, etc.), continue using the existing early-return pattern (`if (!x) return createErrorResponse(...)`). This avoids a full rewrite of all error handling while still eliminating the most common repetition. + +Do NOT mix thrown and returned errors within the same route handler — pick one per handler. `findSessionOrFail` at the top of a handler (throw), then early-returns for everything else. + +### Module-Level Singletons + +Two singletons are imported at the module level in `server.ts` (not on `this`) and used directly by route handlers: + +| Singleton | Import | Used By Routes | +|-----------|--------|---------------| +| `imageWatcher` | `import { imageWatcher } from '../image-watcher.js'` | POST /api/sessions/:id/image-watcher, PUT /api/settings | +| `fileStreamManager` | `import { fileStreamManager } from '../file-stream-manager.js'` | GET /api/sessions/:id/tail-file, DELETE /api/sessions/:id/tail-file/:streamId | + +**Convention**: Route modules should import these singletons directly (not via RouteContext). They are already module-level singletons with no `this` binding, so direct import is simpler and consistent with their existing usage. + ### ScheduledRun type -The `ScheduledRun` type is currently defined locally in `server.ts` (around line 372-389). Move it to `route-context.ts` or a shared types location since route modules need it. +The `ScheduledRun` type is currently defined locally in `server.ts` (around line 131-143). Move it to `route-context.ts` or a shared types location since route modules need it. ### Verification @@ -563,7 +610,10 @@ These routes access WebServer state that must be exposed via RouteContext: | `this.getSessionStateWithRespawn()` | Get session details | | `this.batchTerminalData()` | (Indirectly via session listeners) | | `this.runSummaryTrackers` | Run summary GET | -| `imageWatcher` | Image watcher toggle | +| `this.getGlobalNiceConfig()` | Create session (nice/ionice config) | +| `this.getModelConfig()` | Create session (default model) | +| `this.getClaudeModeConfig()` | Create session (claude mode, allowed tools) | +| `imageWatcher` | Image watcher toggle (import directly, not via ctx) | ### Session Creation Helper @@ -802,6 +852,8 @@ The `getSystemStats()` helper (lines 4710-4753) moves to `system-routes.ts` sinc File routes are the most self-contained group — they use `fs` operations, path validation, and `fileStreamManager`. The only ctx dependencies are `ctx.sessions` (to verify session exists and get working dir) and `ctx.broadcast()` (for screenshot upload notification). +**Module-level singletons**: `fileStreamManager` is imported directly from `'../file-stream-manager.js'` — import it directly in `file-routes.ts`, not via RouteContext. Similarly, the image watcher toggle route in session-routes uses `imageWatcher` from `'../image-watcher.js'` — import directly. + **TOCTOU security**: The file-raw route has a critical `realpathSync()` double-check for symlink TOCTOU protection. Preserve this exactly when moving. ### Push Routes @@ -834,7 +886,7 @@ Quick-start (line 2965, ~160 LOC) is complex: it creates a case directory, write |--------|------|------|---------| | POST | `/api/hook-event` | 4357 | Receive Claude Code hook events | -This route is special: it's exempt from auth (localhost-only), validates with `HookEventSchema`, and broadcasts `hook:{eventName}` events. It also handles Web Push notifications. Place it in `system-routes.ts` or its own `hook-routes.ts`. +This route is special: it's exempt from auth (localhost-only), validates with `HookEventSchema`, and broadcasts `hook:{eventName}` events. It also handles Web Push notifications via `ctx.sendPushNotifications()`. Place it in `system-routes.ts` or its own `hook-routes.ts`. ### SSE Route diff --git a/src/web/middleware/auth.ts b/src/web/middleware/auth.ts new file mode 100644 index 00000000..914d5032 --- /dev/null +++ b/src/web/middleware/auth.ts @@ -0,0 +1,175 @@ +/** + * @fileoverview Authentication and security middleware. + * + * Extracted from server.ts setupRoutes() — handles: + * - HTTP Basic Auth with session cookies + * - Rate limiting (per-IP failure tracking) + * - Security headers (CSP, X-Frame-Options, HSTS) + * - CORS (localhost only) + */ + +import { FastifyInstance } from 'fastify'; +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import { StaleExpirationMap } from '../../utils/index.js'; + +// 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; + +/** State returned from registerAuthMiddleware for cleanup in server stop() */ +export interface AuthState { + authSessions: StaleExpirationMap | null; + authFailures: StaleExpirationMap | null; +} + +/** + * Register HTTP Basic Auth middleware with session cookies and rate limiting. + * Only active when CODEMAN_PASSWORD is set. + * + * @returns AuthState for lifecycle management (dispose on server stop) + */ +export function registerAuthMiddleware(app: FastifyInstance, https: boolean): AuthState { + const state: AuthState = { + authSessions: null, + authFailures: null, + }; + + const authPassword = process.env.CODEMAN_PASSWORD; + if (!authPassword) return state; + + 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 + state.authSessions = new StaleExpirationMap({ + ttlMs: AUTH_SESSION_TTL_MS, + refreshOnGet: true, + }); + + // Failure counter per IP — decay naturally after 15 minutes + state.authFailures = new StaleExpirationMap({ + ttlMs: AUTH_FAILURE_WINDOW_MS, + refreshOnGet: false, + }); + + const authSessions = state.authSessions; + const authFailures = state.authFailures; + + app.addHook('onRequest', (req, reply, done) => { + // Hook events come from local Claude Code hooks (curl from localhost) — no auth headers available. + // Safe: validated by HookEventSchema, only triggers broadcasts. + // Security: restrict bypass to localhost only — prevents forged hook events via tunnel/LAN. + if (req.url === '/api/hook-event' && req.method === 'POST') { + const ip = req.ip; + if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') { + done(); + return; + } + // Non-localhost hook requests fall through to normal auth + } + + const clientIp = req.ip; + + // Rate limit: reject if too many failed attempts from this IP + const failures = 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) + // Use get() instead of has() so refreshOnGet extends the TTL on active sessions + const sessionToken = req.cookies[AUTH_COOKIE_NAME]; + if (sessionToken && authSessions.get(sessionToken) !== undefined) { + done(); + return; + } + + // Check Basic Auth header (timing-safe comparison to prevent side-channel attacks) + const auth = req.headers.authorization; + const authBuf = Buffer.from(auth ?? ''); + const expectedBuf = Buffer.from(expectedHeader); + if (authBuf.length === expectedBuf.length && timingSafeEqual(authBuf, expectedBuf)) { + // 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 (authSessions.size >= MAX_AUTH_SESSIONS) { + const oldestKey = authSessions.keys().next().value; + if (oldestKey !== undefined) authSessions.delete(oldestKey); + } + + authSessions.set(token, clientIp); + + // Reset failure count on successful auth + authFailures.delete(clientIp); + + reply.setCookie(AUTH_COOKIE_NAME, token, { + httpOnly: true, + secure: https, + sameSite: 'lax', + maxAge: AUTH_SESSION_TTL_MS / 1000, // seconds + path: '/', + }); + done(); + return; + } + + // Auth failed — track failure count + authFailures.set(clientIp, failures + 1); + + reply.header('WWW-Authenticate', 'Basic realm="Codeman"'); + reply.code(401).send('Unauthorized'); + }); + + return state; +} + +/** + * Register security headers and CORS middleware on every response. + */ +export function registerSecurityHeaders(app: FastifyInstance, https: boolean): void { + app.addHook('onRequest', (req, reply, done) => { + reply.header('X-Content-Type-Options', 'nosniff'); + reply.header('X-Frame-Options', 'SAMEORIGIN'); + reply.header( + 'Content-Security-Policy', + "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: blob:; connect-src 'self' wss://api.deepgram.com; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'" + ); + if (https) { + reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + + // CORS: restrict to same-origin (localhost) only + const origin = req.headers.origin; + if (origin) { + try { + const url = new URL(origin); + if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1') { + reply.header('Access-Control-Allow-Origin', origin); + reply.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); + reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + reply.header('Access-Control-Max-Age', '86400'); + } + } catch { + // Invalid origin header — do not set CORS headers + } + } + + // Handle CORS preflight + if (req.method === 'OPTIONS') { + reply.code(204).send(); + done(); + return; + } + + done(); + }); +} diff --git a/src/web/ports/config-port.ts b/src/web/ports/config-port.ts new file mode 100644 index 00000000..a8f2e640 --- /dev/null +++ b/src/web/ports/config-port.ts @@ -0,0 +1,22 @@ +/** + * @fileoverview Config port — capabilities for app configuration and settings. + * Route modules that read or modify configuration depend on this port. + */ + +import type { ClaudeMode, NiceConfig } from '../../types.js'; +import type { StateStore } from '../../state-store.js'; + +export interface ConfigPort { + readonly store: StateStore; + readonly port: number; + readonly https: boolean; + readonly testMode: boolean; + readonly serverStartTime: number; + getGlobalNiceConfig(): Promise; + getModelConfig(): Promise<{ defaultModel?: string; agentTypeOverrides?: Record } | null>; + getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }>; + getDefaultClaudeMdPath(): Promise; + getLightState(): unknown; + startTranscriptWatcher(sessionId: string, transcriptPath: string): void; + stopTranscriptWatcher(sessionId: string): void; +} diff --git a/src/web/ports/event-port.ts b/src/web/ports/event-port.ts new file mode 100644 index 00000000..142bd686 --- /dev/null +++ b/src/web/ports/event-port.ts @@ -0,0 +1,14 @@ +/** + * @fileoverview Event port — capabilities for broadcasting events to SSE clients. + * Route modules that need to notify the frontend depend on this port. + */ + +import type { BackgroundTask } from '../../session.js'; + +export interface EventPort { + broadcast(event: string, data: unknown): void; + sendPushNotifications(event: string, data: Record): void; + batchTerminalData(sessionId: string, data: string): void; + broadcastSessionStateDebounced(sessionId: string): void; + batchTaskUpdate(sessionId: string, task: BackgroundTask): void; +} diff --git a/src/web/ports/index.ts b/src/web/ports/index.ts new file mode 100644 index 00000000..b9f7b94c --- /dev/null +++ b/src/web/ports/index.ts @@ -0,0 +1,13 @@ +/** + * @fileoverview Barrel export for all port interfaces. + * + * Ports define the capabilities that route modules can depend on. + * WebServer implements all ports; route modules declare only what they need + * via TypeScript intersection types (e.g., SessionPort & EventPort). + */ + +export type { SessionPort } from './session-port.js'; +export type { EventPort } from './event-port.js'; +export type { RespawnPort } from './respawn-port.js'; +export type { ConfigPort } from './config-port.js'; +export type { InfraPort, ScheduledRun } from './infra-port.js'; diff --git a/src/web/ports/infra-port.ts b/src/web/ports/infra-port.ts new file mode 100644 index 00000000..322cdf37 --- /dev/null +++ b/src/web/ports/infra-port.ts @@ -0,0 +1,38 @@ +/** + * @fileoverview Infra port — capabilities for infrastructure services. + * Route modules that interact with mux, push, teams, tunnel, etc. depend on this port. + */ + +import type { TerminalMultiplexer } from '../../mux-interface.js'; +import type { RunSummaryTracker } from '../../run-summary.js'; +import type { PlanOrchestrator } from '../../plan-orchestrator.js'; +import type { TeamWatcher } from '../../team-watcher.js'; +import type { TunnelManager } from '../../tunnel-manager.js'; +import type { PushSubscriptionStore } from '../../push-store.js'; + +/** A scheduled autonomous run with session lifecycle management */ +export interface ScheduledRun { + id: string; + prompt: string; + workingDir: string; + durationMinutes: number; + startedAt: number; + endAt: number; + status: 'running' | 'completed' | 'failed' | 'stopped'; + sessionId: string | null; + completedTasks: number; + totalCost: number; + logs: string[]; +} + +export interface InfraPort { + readonly mux: TerminalMultiplexer; + readonly runSummaryTrackers: Map; + readonly activePlanOrchestrators: Map; + readonly scheduledRuns: Map; + readonly teamWatcher: TeamWatcher; + readonly tunnelManager: TunnelManager; + readonly pushStore: PushSubscriptionStore; + startScheduledRun(prompt: string, workingDir: string, durationMinutes: number): Promise; + stopScheduledRun(id: string): Promise; +} diff --git a/src/web/ports/respawn-port.ts b/src/web/ports/respawn-port.ts new file mode 100644 index 00000000..3ff5fe66 --- /dev/null +++ b/src/web/ports/respawn-port.ts @@ -0,0 +1,17 @@ +/** + * @fileoverview Respawn port — capabilities for respawn controller management. + * Route modules that control respawn cycling depend on this port. + */ + +import type { Session } from '../../session.js'; +import type { RespawnController, RespawnConfig } from '../../respawn-controller.js'; +import type { PersistedRespawnConfig } from '../../types.js'; + +export interface RespawnPort { + readonly respawnControllers: Map; + readonly respawnTimers: Map; + setupRespawnListeners(sessionId: string, controller: RespawnController): void; + setupTimedRespawn(sessionId: string, durationMinutes: number): void; + restoreRespawnController(session: Session, config: PersistedRespawnConfig, source: string): void; + saveRespawnConfig(sessionId: string, config: RespawnConfig, durationMinutes?: number): void; +} diff --git a/src/web/ports/session-port.ts b/src/web/ports/session-port.ts new file mode 100644 index 00000000..b0dc63ba --- /dev/null +++ b/src/web/ports/session-port.ts @@ -0,0 +1,15 @@ +/** + * @fileoverview Session port — capabilities for session lifecycle management. + * Route modules that manage sessions depend on this port. + */ + +import type { Session } from '../../session.js'; + +export interface SessionPort { + readonly sessions: ReadonlyMap; + cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise; + setupSessionListeners(session: Session): Promise; + persistSessionState(session: Session): void; + persistSessionStateNow(session: Session): void; + getSessionStateWithRespawn(session: Session): unknown; +} diff --git a/src/web/route-helpers.ts b/src/web/route-helpers.ts new file mode 100644 index 00000000..428038b7 --- /dev/null +++ b/src/web/route-helpers.ts @@ -0,0 +1,149 @@ +/** + * @fileoverview Shared helper functions for route modules. + * + * Contains pure functions extracted from server.ts and a session lookup helper + * that replaces ~43 inline not-found checks across route handlers. + */ + +import { join } from 'node:path'; +import { Session } from '../session.js'; +import { ApiErrorCode, createErrorResponse } from '../types.js'; +import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js'; +import type { SessionPort } from './ports/session-port.js'; +import type { EventPort } from './ports/event-port.js'; + +// Maximum hook data size (prevents oversized SSE broadcasts) +const MAX_HOOK_DATA_SIZE = 8 * 1024; + +/** + * Look up a session by ID or throw a structured error. + * Replaces the pattern: `const session = sessions.get(id); if (!session) return createErrorResponse(...)`. + */ +export function findSessionOrFail(ctx: SessionPort, sessionId: string): Session { + const session = ctx.sessions.get(sessionId); + if (!session) { + throw Object.assign(new Error(`Session ${sessionId} not found`), { + statusCode: 404, + body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`), + }); + } + return session; +} + +/** + * Formats uptime in seconds to a human-readable string (e.g., "1d 2h 30m 15s"). + */ +export function formatUptime(seconds: number): string { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + const parts: string[] = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (secs > 0 || parts.length === 0) parts.push(`${secs}s`); + + return parts.join(' '); +} + +/** + * Sanitizes hook event data before broadcasting via SSE. + * Extracts only relevant fields and limits total size to prevent + * oversized payloads from being broadcast to all connected clients. + */ +export function sanitizeHookData(data: Record | null | undefined): Record { + if (!data || typeof data !== 'object') return {}; + + // Only forward known safe fields from Claude Code hook stdin + const safeFields: Record = {}; + const allowedKeys = [ + 'hook_event_name', + 'tool_name', + 'tool_input', + 'session_id', + 'cwd', + 'permission_mode', + 'stop_hook_active', + 'transcript_path', + ]; + + for (const key of allowedKeys) { + if (key in data && data[key] !== undefined) { + safeFields[key] = data[key]; + } + } + + // For tool_input, extract only summary fields (not full file content) + if (safeFields.tool_input && typeof safeFields.tool_input === 'object') { + const input = safeFields.tool_input as Record; + const summary: Record = {}; + if (input.command) summary.command = String(input.command).slice(0, 500); + if (input.file_path) summary.file_path = String(input.file_path).slice(0, 500); + if (input.description) summary.description = String(input.description).slice(0, 200); + if (input.query) summary.query = String(input.query).slice(0, 200); + if (input.url) summary.url = String(input.url).slice(0, 500); + if (input.pattern) summary.pattern = String(input.pattern).slice(0, 200); + if (input.prompt) summary.prompt = String(input.prompt).slice(0, 200); + safeFields.tool_input = summary; + } + + // Final size check - drop if serialized data exceeds limit + const serialized = JSON.stringify(safeFields); + if (serialized.length > MAX_HOOK_DATA_SIZE) { + return { tool_name: safeFields.tool_name, _truncated: true }; + } + + return safeFields; +} + +/** + * Auto-configure Ralph tracker for a session. + * + * Priority order: + * 1. .claude/ralph-loop.local.md (official Ralph Wiggum plugin state) + * 2. CLAUDE.md tags (fallback) + * + * The ralph-loop.local.md file has priority because it contains + * the exact configuration from an active Ralph loop session. + */ +export function autoConfigureRalph(session: Session, workingDir: string, ctx: EventPort): void { + // First, try to read the official Ralph Wiggum plugin state file + const ralphConfig = parseRalphLoopConfig(workingDir); + + if (ralphConfig && ralphConfig.completionPromise) { + session.ralphTracker.enable(); + session.ralphTracker.startLoop(ralphConfig.completionPromise, ralphConfig.maxIterations ?? undefined); + + // Restore iteration count if available + if (ralphConfig.iteration > 0) { + // The tracker's cycleCount will be updated when we detect iteration patterns + // in the terminal output, but we can set maxIterations now + console.log(`[auto-detect] Ralph loop at iteration ${ralphConfig.iteration}/${ralphConfig.maxIterations ?? '∞'}`); + } + + console.log( + `[auto-detect] Configured Ralph loop for session ${session.id} from ralph-loop.local.md: ${ralphConfig.completionPromise}` + ); + ctx.broadcast('session:ralphLoopUpdate', { + sessionId: session.id, + state: session.ralphTracker.loopState, + }); + return; + } + + // Fallback: try CLAUDE.md + const claudeMdPath = join(workingDir, 'CLAUDE.md'); + const completionPhrase = extractCompletionPhrase(claudeMdPath); + + if (completionPhrase) { + session.ralphTracker.enable(); + session.ralphTracker.startLoop(completionPhrase); + console.log(`[auto-detect] Configured Ralph loop for session ${session.id} from CLAUDE.md: ${completionPhrase}`); + ctx.broadcast('session:ralphLoopUpdate', { + sessionId: session.id, + state: session.ralphTracker.loopState, + }); + } +} diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts new file mode 100644 index 00000000..e6145a08 --- /dev/null +++ b/src/web/routes/case-routes.ts @@ -0,0 +1,465 @@ +/** + * @fileoverview Case management routes. + * Handles CRUD for cases (directories under ~/codeman-cases and linked folders), + * fix-plan reading, and ralph-wizard file serving. + */ + +import { FastifyInstance } from 'fastify'; +import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { join, resolve, relative, isAbsolute } from 'node:path'; +import { homedir } from 'node:os'; +import type { ApiResponse, CaseInfo } from '../../types.js'; +import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; +import { CreateCaseSchema, LinkCaseSchema } from '../schemas.js'; +import { generateClaudeMd } from '../../templates/claude-md.js'; +import { writeHooksConfig } from '../../hooks-config.js'; +import type { EventPort, ConfigPort } from '../ports/index.js'; + +const casesDir = join(homedir(), 'codeman-cases'); + +export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void { + // ============ Case CRUD ============ + + app.get('/api/cases', async (): Promise => { + const cases: CaseInfo[] = []; + + // Get cases from casesDir + try { + const entries = await fs.readdir(casesDir, { withFileTypes: true }); + for (const e of entries) { + if (e.isDirectory()) { + cases.push({ + name: e.name, + path: join(casesDir, e.name), + hasClaudeMd: existsSync(join(casesDir, e.name, 'CLAUDE.md')), + }); + } + } + } catch { + // casesDir may not exist yet + } + + // Get linked cases + const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); + try { + const linkedCases: Record = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8')); + for (const [name, path] of Object.entries(linkedCases)) { + // Only add if not already in cases (avoid duplicates) and path exists + if (!cases.some((c) => c.name === name) && existsSync(path)) { + cases.push({ + name, + path, + hasClaudeMd: existsSync(join(path, 'CLAUDE.md')), + }); + } + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn('[Server] Failed to read linked cases:', err); + } + } + + return cases; + }); + + app.post('/api/cases', async (req): Promise> => { + const result = CreateCaseSchema.safeParse(req.body); + if (!result.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); + } + const { name, description } = result.data; + + const casePath = join(casesDir, name); + + // Security: Path traversal protection - use relative path check + const resolvedPath = resolve(casePath); + const resolvedBase = resolve(casesDir); + const relPath = relative(resolvedBase, resolvedPath); + if (relPath.startsWith('..') || isAbsolute(relPath)) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path'); + } + + if (existsSync(casePath)) { + return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists'); + } + + try { + mkdirSync(casePath, { recursive: true }); + mkdirSync(join(casePath, 'src'), { recursive: true }); + + // Read settings to get custom template path + const templatePath = await ctx.getDefaultClaudeMdPath(); + const claudeMd = generateClaudeMd(name, description || '', templatePath); + writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd); + + // Write .claude/settings.local.json with hooks for desktop notifications + await writeHooksConfig(casePath); + + ctx.broadcast('case:created', { name, path: casePath }); + + return { success: true, data: { case: { name, path: casePath } } }; + } catch (err) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + } + }); + + // Link an existing folder as a case + app.post('/api/cases/link', async (req): Promise> => { + const lcResult = LinkCaseSchema.safeParse(req.body); + if (!lcResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const { name, path: folderPath } = lcResult.data; + + // Expand ~ to home directory + const expandedPath = folderPath.startsWith('~') ? join(homedir(), folderPath.slice(1)) : folderPath; + + // Validate the folder exists + if (!existsSync(expandedPath)) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${expandedPath}`); + } + + // Check if case name already exists in casesDir + const casePath = join(casesDir, name); + if (existsSync(casePath)) { + return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.'); + } + + // Load existing linked cases + const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); + let linkedCases: Record = {}; + try { + linkedCases = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8')); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn('[Server] Failed to read linked cases:', err); + } + } + + // Check if name is already linked + if (linkedCases[name]) { + return createErrorResponse( + ApiErrorCode.ALREADY_EXISTS, + `Case "${name}" is already linked to ${linkedCases[name]}` + ); + } + + // Save the linked case + linkedCases[name] = expandedPath; + try { + const codemanDir = join(homedir(), '.codeman'); + if (!existsSync(codemanDir)) { + mkdirSync(codemanDir, { recursive: true }); + } + await fs.writeFile(linkedCasesFile, JSON.stringify(linkedCases, null, 2)); + ctx.broadcast('case:linked', { name, path: expandedPath }); + return { success: true, data: { case: { name, path: expandedPath } } }; + } catch (err) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + } + }); + + app.get('/api/cases/:name', async (req) => { + const { name } = req.params as { name: string }; + + // First check linked cases + const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); + try { + const linkedCases: Record = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8')); + if (linkedCases[name]) { + const linkedPath = linkedCases[name]; + return { + name, + path: linkedPath, + hasClaudeMd: existsSync(join(linkedPath, 'CLAUDE.md')), + linked: true, + }; + } + } catch { + // ENOENT or parse errors - fall through to casesDir check + } + + // Then check casesDir + const casePath = join(casesDir, name); + + if (!existsSync(casePath)) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found'); + } + + return { + name, + path: casePath, + hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')), + }; + }); + + // Read @fix_plan.md from a case directory (for wizard to detect existing plans) + app.get('/api/cases/:name/fix-plan', async (req) => { + const { name } = req.params as { name: string }; + + // Get case path (check linked cases first, then casesDir) + let casePath: string | null = null; + + const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); + try { + const linkedCases: Record = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8')); + if (linkedCases[name]) { + casePath = linkedCases[name]; + } + } catch { + // ENOENT or parse errors - fall through to casesDir + } + + if (!casePath) { + casePath = join(casesDir, name); + } + + const fixPlanPath = join(casePath, '@fix_plan.md'); + + if (!existsSync(fixPlanPath)) { + return { success: true, exists: false, content: null, todos: [] }; + } + + try { + const content = await fs.readFile(fixPlanPath, 'utf-8'); + + // Parse todos from the content (similar to ralph-tracker's importFixPlanMarkdown) + const todos: Array<{ + content: string; + status: 'pending' | 'in_progress' | 'completed'; + priority: string | null; + }> = []; + const todoPattern = /^-\s*\[([ xX-])\]\s*(.+)$/; + const p0HeaderPattern = /^##\s*(High Priority|Critical|P0|Critical Path)/i; + const p1HeaderPattern = /^##\s*(Standard|P1|Medium Priority)/i; + const p2HeaderPattern = /^##\s*(Nice to Have|P2|Low Priority)/i; + const completedHeaderPattern = /^##\s*Completed/i; + + let currentPriority: string | null = null; + let inCompletedSection = false; + + for (const line of content.split('\n')) { + const trimmed = line.trim(); + + if (p0HeaderPattern.test(trimmed)) { + currentPriority = 'P0'; + inCompletedSection = false; + continue; + } + if (p1HeaderPattern.test(trimmed)) { + currentPriority = 'P1'; + inCompletedSection = false; + continue; + } + if (p2HeaderPattern.test(trimmed)) { + currentPriority = 'P2'; + inCompletedSection = false; + continue; + } + if (completedHeaderPattern.test(trimmed)) { + inCompletedSection = true; + continue; + } + + const match = trimmed.match(todoPattern); + if (match) { + const [, checkboxState, taskContent] = match; + let status: 'pending' | 'in_progress' | 'completed'; + + if (inCompletedSection || checkboxState === 'x' || checkboxState === 'X') { + status = 'completed'; + } else if (checkboxState === '-') { + status = 'in_progress'; + } else { + status = 'pending'; + } + + todos.push({ + content: taskContent.trim(), + status, + priority: inCompletedSection ? null : currentPriority, + }); + } + } + + // Calculate stats in a single pass for better performance + let pending = 0, + inProgress = 0, + completed = 0; + for (const t of todos) { + if (t.status === 'pending') pending++; + else if (t.status === 'in_progress') inProgress++; + else if (t.status === 'completed') completed++; + } + const stats = { total: todos.length, pending, inProgress, completed }; + + return { + success: true, + exists: true, + content, + todos, + stats, + }; + } catch (err) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read @fix_plan.md: ${err}`); + } + }); + + // ============ Ralph Wizard File Endpoints ============ + + app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => { + const { caseName } = req.params as { caseName: string }; + let casePath = join(casesDir, caseName); + + // Security: Path traversal protection - use relative path check + const resolvedCase = resolve(casePath); + const resolvedBase = resolve(casesDir); + const relPath = relative(resolvedBase, resolvedCase); + if (relPath.startsWith('..') || isAbsolute(relPath)) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); + } + + // Check linked cases if path doesn't exist + if (!existsSync(casePath)) { + const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); + try { + const linkedCases: Record = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8')); + if (linkedCases[caseName]) { + casePath = linkedCases[caseName]; + } + } catch { + // No linked cases file + } + } + + const wizardDir = join(casePath, 'ralph-wizard'); + + if (!existsSync(wizardDir)) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Ralph wizard directory not found'); + } + + // List all subdirectories and their files + const files: Array<{ agentType: string; promptFile?: string; resultFile?: string }> = []; + const entries = readdirSync(wizardDir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory()) { + const agentDir = join(wizardDir, entry.name); + const agentFiles: { agentType: string; promptFile?: string; resultFile?: string } = { + agentType: entry.name, + }; + + if (existsSync(join(agentDir, 'prompt.md'))) { + agentFiles.promptFile = `${entry.name}/prompt.md`; + } + if (existsSync(join(agentDir, 'result.json'))) { + agentFiles.resultFile = `${entry.name}/result.json`; + } + + if (agentFiles.promptFile || agentFiles.resultFile) { + files.push(agentFiles); + } + } + } + + return { success: true, data: { files, caseName } }; + }); + + // Read a specific ralph-wizard file + // Cache disabled to ensure fresh prompts when starting new plan generations + app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req, reply) => { + const { caseName, filePath } = req.params as { caseName: string; filePath: string }; + let casePath = join(casesDir, caseName); + + // Prevent browser caching - prompts change between plan generations + reply.header('Cache-Control', 'no-store, no-cache, must-revalidate'); + reply.header('Pragma', 'no-cache'); + reply.header('Expires', '0'); + + // Security: Path traversal protection for case name - use relative path check + const resolvedCase = resolve(casePath); + const resolvedBase = resolve(casesDir); + const relPath = relative(resolvedBase, resolvedCase); + if (relPath.startsWith('..') || isAbsolute(relPath)) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); + } + + // Check linked cases if path doesn't exist + if (!existsSync(casePath)) { + const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); + try { + const linkedCases: Record = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8')); + if (linkedCases[caseName]) { + casePath = linkedCases[caseName]; + } + } catch { + // No linked cases file + } + } + + const wizardDir = join(casePath, 'ralph-wizard'); + + // Decode the file path (it may be URL encoded) + const decodedPath = decodeURIComponent(filePath); + const fullPath = join(wizardDir, decodedPath); + + // Security: ensure path is within wizard directory + const resolvedPath = resolve(fullPath); + const resolvedWizard = resolve(wizardDir); + if (!resolvedPath.startsWith(resolvedWizard)) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid file path'); + } + + let content: string; + try { + content = await fs.readFile(fullPath, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'); + } + throw err; + } + const isJson = filePath.endsWith('.json'); + + // Parse JSON content safely (may contain invalid JSON or unescaped control characters) + let parsed: unknown = null; + if (isJson) { + try { + parsed = JSON.parse(content); + } catch { + // Try repairing common JSON issues (unescaped control characters, trailing commas) + try { + let repaired = content; + // Fix trailing commas before closing brackets + repaired = repaired.replace(/,(\s*[\]}])/g, '$1'); + // Fix unescaped control characters within JSON strings + repaired = repaired.replace(/"([^"\\]|\\.)*"/g, (match) => { + return match + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') + .replace( + // eslint-disable-next-line no-control-regex + /[\x00-\x1f]/g, + (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}` + ); + }); + parsed = JSON.parse(repaired); + } catch { + // Still invalid - return null for parsed, content available as raw string + } + } + } + + return { + success: true, + data: { + content, + filePath: decodedPath, + isJson, + parsed, + }, + }; + }); +} diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts new file mode 100644 index 00000000..573a00da --- /dev/null +++ b/src/web/routes/file-routes.ts @@ -0,0 +1,385 @@ +/** + * @fileoverview File browser and streaming routes. + * Provides directory listing, file content preview, raw file serving, and tail streaming. + */ + +import { FastifyInstance } from 'fastify'; +import { join, resolve, relative, isAbsolute } from 'node:path'; +import { realpathSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; +import { fileStreamManager } from '../../file-stream-manager.js'; +import { findSessionOrFail } from '../route-helpers.js'; +import type { SessionPort } from '../ports/index.js'; + +export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void { + // File tree listing + app.get('/api/sessions/:id/files', async (req) => { + const { id } = req.params as { id: string }; + const { depth, showHidden } = req.query as { depth?: string; showHidden?: string }; + const session = findSessionOrFail(ctx, id); + + const maxDepth = Math.min(parseInt(depth || '5', 10), 10); + const includeHidden = showHidden === 'true'; + const workingDir = session.workingDir; + + // Default excludes - large/generated directories + const excludeDirs = new Set([ + '.git', + 'node_modules', + 'dist', + 'build', + '__pycache__', + '.cache', + '.next', + '.nuxt', + 'coverage', + '.venv', + 'venv', + '.tox', + 'target', + 'vendor', + ]); + + interface FileTreeNode { + name: string; + path: string; + type: 'file' | 'directory'; + size?: number; + extension?: string; + children?: FileTreeNode[]; + } + + let totalFiles = 0; + let totalDirectories = 0; + let truncated = false; + const maxFiles = 5000; + + const scanDirectory = async (dirPath: string, currentDepth: number): Promise => { + if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles) { + truncated = true; + return []; + } + + try { + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + const nodes: FileTreeNode[] = []; + + // Sort: directories first, then alphabetically + entries.sort((a, b) => { + if (a.isDirectory() && !b.isDirectory()) return -1; + if (!a.isDirectory() && b.isDirectory()) return 1; + return a.name.localeCompare(b.name); + }); + + for (const entry of entries) { + if (totalFiles + totalDirectories > maxFiles) { + truncated = true; + break; + } + + // Skip hidden files unless requested + if (!includeHidden && entry.name.startsWith('.')) continue; + + // Skip excluded directories + if (entry.isDirectory() && excludeDirs.has(entry.name)) continue; + + const fullPath = join(dirPath, entry.name); + const relativePath = fullPath.slice(workingDir.length + 1); + + if (entry.isDirectory()) { + totalDirectories++; + const children = await scanDirectory(fullPath, currentDepth + 1); + nodes.push({ + name: entry.name, + path: relativePath, + type: 'directory', + children, + }); + } else { + totalFiles++; + const ext = entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined; + let size: number | undefined; + try { + const stat = await fs.stat(fullPath); + size = stat.size; + } catch { + // Skip if can't stat + } + nodes.push({ + name: entry.name, + path: relativePath, + type: 'file', + size, + extension: ext, + }); + } + } + + return nodes; + } catch { + // Can't read directory (permission denied, etc.) + return []; + } + }; + + const tree = await scanDirectory(workingDir, 1); + + return { + success: true, + data: { + root: workingDir, + tree, + totalFiles, + totalDirectories, + truncated, + }, + }; + }); + + // Get file content for preview (File Browser) + app.get('/api/sessions/:id/file-content', async (req) => { + const { id } = req.params as { id: string }; + const { path: filePath, lines, raw } = req.query as { path?: string; lines?: string; raw?: string }; + const session = findSessionOrFail(ctx, id); + + if (!filePath) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'); + } + + // Validate path is within working directory (security: resolve symlinks to prevent traversal) + const fullPath = resolve(session.workingDir, filePath); + let resolvedPath: string; + try { + resolvedPath = realpathSync(fullPath); + } catch { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'); + } + const relativePath = relative(session.workingDir, resolvedPath); + if (relativePath.startsWith('..') || isAbsolute(relativePath)) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory'); + } + + try { + const stat = await fs.stat(resolvedPath); + + // Check if it's a binary/media file + const ext = filePath.split('.').pop()?.toLowerCase() || ''; + const binaryExts = new Set([ + 'png', + 'jpg', + 'jpeg', + 'gif', + 'webp', + 'ico', + 'svg', + 'bmp', + 'mp4', + 'webm', + 'mov', + 'avi', + 'mp3', + 'wav', + 'ogg', + 'pdf', + 'zip', + 'tar', + 'gz', + 'exe', + 'dll', + 'so', + 'woff', + 'woff2', + 'ttf', + 'eot', + ]); + const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']); + const videoExts = new Set(['mp4', 'webm', 'mov', 'avi']); + + if (raw === 'true' || binaryExts.has(ext)) { + // Return metadata for binary files + return { + success: true, + data: { + path: filePath, + size: stat.size, + type: imageExts.has(ext) ? 'image' : videoExts.has(ext) ? 'video' : 'binary', + extension: ext, + url: `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`, + }, + }; + } + + // Validate file size before reading (DoS protection - prevent memory exhaustion) + const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024; // 10MB + if (stat.size > MAX_TEXT_FILE_SIZE) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + `File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit)` + ); + } + + // Read text file with line limit (bounded to prevent DoS) + const MAX_LINES_LIMIT = 10000; + const maxLines = Math.min(parseInt(lines || '500', 10) || 500, MAX_LINES_LIMIT); + const content = await fs.readFile(resolvedPath, 'utf-8'); + const allLines = content.split('\n'); + const truncatedContent = allLines.length > maxLines; + const displayContent = truncatedContent ? allLines.slice(0, maxLines).join('\n') : content; + + return { + success: true, + data: { + path: filePath, + content: displayContent, + size: stat.size, + totalLines: allLines.length, + truncated: truncatedContent, + extension: ext, + }, + }; + } catch (err) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`); + } + }); + + // Serve raw file content (for images/binary files) + app.get('/api/sessions/:id/file-raw', async (req, reply) => { + const { id } = req.params as { id: string }; + const { path: filePath } = req.query as { path?: string }; + const session = findSessionOrFail(ctx, id); + + if (!filePath) { + reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter')); + return; + } + + // Validate path is within working directory (security: resolve symlinks to prevent traversal) + const fullPath = resolve(session.workingDir, filePath); + let resolvedPath: string; + try { + resolvedPath = realpathSync(fullPath); + } catch { + reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found')); + return; + } + const relativePath = relative(session.workingDir, resolvedPath); + if (relativePath.startsWith('..') || isAbsolute(relativePath)) { + reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory')); + return; + } + + try { + // Validate file size before reading (DoS protection - prevent memory exhaustion) + const MAX_RAW_FILE_SIZE = 50 * 1024 * 1024; // 50MB for raw files + const stat = await fs.stat(resolvedPath); + if (stat.size > MAX_RAW_FILE_SIZE) { + reply + .code(400) + .send( + createErrorResponse( + ApiErrorCode.INVALID_INPUT, + `File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_RAW_FILE_SIZE / 1024 / 1024}MB limit)` + ) + ); + return; + } + + const ext = filePath.split('.').pop()?.toLowerCase() || ''; + const mimeTypes: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + ico: 'image/x-icon', + bmp: 'image/bmp', + mp4: 'video/mp4', + webm: 'video/webm', + mov: 'video/quicktime', + mp3: 'audio/mpeg', + wav: 'audio/wav', + ogg: 'audio/ogg', + pdf: 'application/pdf', + json: 'application/json', + }; + + const content = await fs.readFile(resolvedPath); + reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream'); + reply.send(content); + } catch (err) { + reply + .code(500) + .send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`)); + } + }); + + // Stream file content via tail -f (SSE endpoint) + app.get('/api/sessions/:id/tail-file', async (req, reply) => { + const { id } = req.params as { id: string }; + const { path: filePath, lines } = req.query as { path?: string; lines?: string }; + const session = findSessionOrFail(ctx, id); + + if (!filePath) { + reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter')); + return; + } + + // Set up SSE headers + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + + // Track stream for cleanup + const streamRef: { id?: string } = {}; + + // Create the file stream + const result = await fileStreamManager.createStream({ + sessionId: id, + filePath, + workingDir: session.workingDir, + lines: lines ? parseInt(lines, 10) : undefined, + onData: (data) => { + // Send data as SSE event + reply.raw.write(`data: ${JSON.stringify({ type: 'data', content: data })}\n\n`); + }, + onEnd: () => { + reply.raw.write(`data: ${JSON.stringify({ type: 'end' })}\n\n`); + reply.raw.end(); + }, + onError: (error) => { + reply.raw.write(`data: ${JSON.stringify({ type: 'error', error })}\n\n`); + }, + }); + + if (!result.success) { + reply.raw.write(`data: ${JSON.stringify({ type: 'error', error: result.error })}\n\n`); + reply.raw.end(); + return; + } + + streamRef.id = result.streamId; + + // Notify client of successful connection + reply.raw.write(`data: ${JSON.stringify({ type: 'connected', streamId: result.streamId, filePath })}\n\n`); + + // Handle client disconnect + req.raw.on('close', () => { + if (streamRef.id) { + fileStreamManager.closeStream(streamRef.id); + } + }); + }); + + // Close a file stream + app.delete('/api/sessions/:id/tail-file/:streamId', async (req) => { + const { id, streamId } = req.params as { id: string; streamId: string }; + findSessionOrFail(ctx, id); // Validates session exists + const closed = fileStreamManager.closeStream(streamId); + return { success: closed }; + }); +} diff --git a/src/web/routes/hook-event-routes.ts b/src/web/routes/hook-event-routes.ts new file mode 100644 index 00000000..a2ee1ced --- /dev/null +++ b/src/web/routes/hook-event-routes.ts @@ -0,0 +1,67 @@ +/** + * @fileoverview Hook event route. + * Receives Claude Code hook events and broadcasts to SSE clients. + * This endpoint bypasses auth (Claude Code hooks curl from localhost). + */ + +import { FastifyInstance } from 'fastify'; +import { ApiErrorCode, createErrorResponse } from '../../types.js'; +import { HookEventSchema, isValidWorkingDir } from '../schemas.js'; +import { sanitizeHookData } from '../route-helpers.js'; +import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js'; + +export function registerHookEventRoutes( + app: FastifyInstance, + ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort +): void { + app.post('/api/hook-event', async (req) => { + const result = HookEventSchema.safeParse(req.body); + if (!result.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); + } + const { event, sessionId, data } = result.data; + if (!ctx.sessions.has(sessionId)) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + // Signal the respawn controller based on hook event type + const controller = ctx.respawnControllers.get(sessionId); + if (controller) { + if (event === 'elicitation_dialog') { + // Block auto-accept for question prompts + controller.signalElicitation(); + } else if (event === 'stop') { + // DEFINITIVE idle signal - Claude finished responding + controller.signalStopHook(); + } else if (event === 'idle_prompt') { + // DEFINITIVE idle signal - Claude has been idle for 60+ seconds + controller.signalIdlePrompt(); + } + } + + // Start transcript watching if transcript_path is provided and safe + if (data && 'transcript_path' in data) { + const transcriptPath = String(data.transcript_path); + if (transcriptPath && isValidWorkingDir(transcriptPath)) { + ctx.startTranscriptWatcher(sessionId, transcriptPath); + } + } + + // Sanitize forwarded data: only include known safe fields, limit size + const safeData = sanitizeHookData(data); + ctx.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now(), ...safeData }); + + // Send push notifications for hook events + const session = ctx.sessions.get(sessionId); + const sessionName = session?.name ?? sessionId.slice(0, 8); + ctx.sendPushNotifications(`hook:${event}`, { sessionId, sessionName, ...safeData }); + + // Track in run summary + const summaryTracker = ctx.runSummaryTrackers.get(sessionId); + if (summaryTracker) { + summaryTracker.recordHookEvent(event, safeData); + } + + return { success: true }; + }); +} diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts new file mode 100644 index 00000000..26248e2d --- /dev/null +++ b/src/web/routes/index.ts @@ -0,0 +1,16 @@ +/** + * @fileoverview Barrel export for all route modules. + */ + +export { registerPushRoutes } from './push-routes.js'; +export { registerTeamRoutes } from './team-routes.js'; +export { registerMuxRoutes } from './mux-routes.js'; +export { registerFileRoutes } from './file-routes.js'; +export { registerScheduledRoutes } from './scheduled-routes.js'; +export { registerSystemRoutes } from './system-routes.js'; +export { registerHookEventRoutes } from './hook-event-routes.js'; +export { registerCaseRoutes } from './case-routes.js'; +export { registerSessionRoutes } from './session-routes.js'; +export { registerRespawnRoutes } from './respawn-routes.js'; +export { registerRalphRoutes } from './ralph-routes.js'; +export { registerPlanRoutes } from './plan-routes.js'; diff --git a/src/web/routes/mux-routes.ts b/src/web/routes/mux-routes.ts new file mode 100644 index 00000000..2138f442 --- /dev/null +++ b/src/web/routes/mux-routes.ts @@ -0,0 +1,41 @@ +/** + * @fileoverview Mux (tmux) session management routes. + * Provides mux session listing, killing, reconciliation, and stats control. + */ + +import { FastifyInstance } from 'fastify'; +import type { InfraPort } from '../ports/index.js'; + +// Stats collection interval (2 seconds) — matches server.ts constant +const STATS_COLLECTION_INTERVAL_MS = 2000; + +export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void { + app.get('/api/mux-sessions', async () => { + const sessions = await ctx.mux.getSessionsWithStats(); + return { + sessions, + muxAvailable: ctx.mux.isAvailable(), + }; + }); + + app.delete('/api/mux-sessions/:sessionId', async (req) => { + const { sessionId } = req.params as { sessionId: string }; + const success = await ctx.mux.killSession(sessionId); + return { success }; + }); + + app.post('/api/mux-sessions/reconcile', async () => { + const result = await ctx.mux.reconcileSessions(); + return result; + }); + + app.post('/api/mux-sessions/stats/start', async () => { + ctx.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS); + return { success: true }; + }); + + app.post('/api/mux-sessions/stats/stop', async () => { + ctx.mux.stopStatsCollection(); + return { success: true }; + }); +} diff --git a/src/web/routes/plan-routes.ts b/src/web/routes/plan-routes.ts new file mode 100644 index 00000000..2f7173dd --- /dev/null +++ b/src/web/routes/plan-routes.ts @@ -0,0 +1,461 @@ +/** + * @fileoverview Plan generation and management routes. + * Covers AI-powered plan generation (simple + detailed orchestration), + * plan task CRUD, checkpoints, version history, and rollback. + */ + +import { FastifyInstance } from 'fastify'; +import { join, resolve, relative, isAbsolute } from 'node:path'; +import { existsSync, rmSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { Session } from '../../session.js'; +import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js'; +import { PlanOrchestrator, type PlanItem, type DetailedPlanResult } from '../../plan-orchestrator.js'; +import { + GeneratePlanSchema, + GeneratePlanDetailedSchema, + CancelPlanSchema, + PlanTaskUpdateSchema, + PlanTaskAddSchema, +} from '../schemas.js'; +import { findSessionOrFail } from '../route-helpers.js'; +import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js'; + +export function registerPlanRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort & InfraPort): void { + // ============ Plan Generation Endpoints ============ + + app.post('/api/generate-plan', async (req): Promise => { + const gpResult = GeneratePlanSchema.safeParse(req.body); + if (!gpResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const { taskDescription, detailLevel = 'standard' } = gpResult.data; + + // Build sophisticated prompt based on Ralph Wiggum methodology + const detailConfig = { + brief: { style: 'high-level milestones', testDepth: 'basic' }, + standard: { style: 'balanced implementation steps', testDepth: 'thorough' }, + detailed: { + style: 'granular sub-tasks with full TDD coverage', + testDepth: 'comprehensive', + }, + }; + const levelConfig = detailConfig[detailLevel] || detailConfig.standard; + + const prompt = `You are an expert software architect breaking down a task into a thorough implementation plan. + +## TASK TO IMPLEMENT +${taskDescription} + +## YOUR MISSION +Create a detailed, actionable implementation plan following Test-Driven Development (TDD) methodology. +Think deeply about: +- What are ALL the components, modules, and features needed? +- What could go wrong? Add defensive steps for error handling. +- How will we verify each part works? Tests before implementation. +- What edge cases need handling? +- What's the logical order of dependencies? + +## DETAIL LEVEL: ${detailLevel.toUpperCase()} +Style: ${levelConfig.style} +Generate as many steps as needed to properly cover the task - don't artificially limit yourself. +For complex projects, this could be 30, 50, or even 100+ steps. Quality over brevity. + +## PLAN STRUCTURE + +Your plan MUST include these phases in order: + +### Phase 1: Foundation & Setup +- Project structure, dependencies, configuration +- Database schemas, type definitions, interfaces + +### Phase 2: Core Implementation (TDD Cycle) +For EACH feature: +1. Write failing tests first (unit tests) +2. Implement the feature +3. Run tests, debug until passing +4. Refactor if needed + +### Phase 3: Integration & Edge Cases +- Integration tests for feature interactions +- Edge case handling (errors, boundaries, invalid input) +- Error messages and user feedback + +### Phase 4: Verification & Hardening +- Run full test suite +- Fix any failing tests +- Add missing test coverage +- Final verification that ALL requirements are met + +## OUTPUT FORMAT +Return ONLY a JSON array. Each item MUST have: +- id: unique identifier (e.g., "P0-001", "P1-002") +- content: specific action (verb phrase, 15-120 chars, be descriptive!) +- priority: "P0" (critical/blocking), "P1" (required), "P2" (enhancement) +- verificationCriteria: HOW to verify this step is complete (required!) +- tddPhase: "setup" | "test" | "impl" | "verify" +- dependencies: array of task IDs this depends on (empty if none) + +## EXAMPLE OUTPUT +[ + {"id": "P0-001", "content": "Create project structure with src/, tests/, and config directories", "priority": "P0", "verificationCriteria": "Directories exist, package.json initialized", "tddPhase": "setup", "dependencies": []}, + {"id": "P0-002", "content": "Define TypeScript interfaces for User, Session, and AuthToken types", "priority": "P0", "verificationCriteria": "Types compile without errors, exported from types.ts", "tddPhase": "setup", "dependencies": ["P0-001"]}, + {"id": "P0-003", "content": "Write failing unit tests for password hashing (valid password, empty, too short)", "priority": "P0", "verificationCriteria": "Tests exist, fail with 'not implemented'", "tddPhase": "test", "dependencies": ["P0-002"]}, + {"id": "P0-004", "content": "Implement password hashing with bcrypt, configurable salt rounds", "priority": "P0", "verificationCriteria": "npm test -- --grep='password' passes", "tddPhase": "impl", "dependencies": ["P0-003"]}, + {"id": "P0-005", "content": "Write failing tests for JWT token generation and validation", "priority": "P0", "verificationCriteria": "Tests exist, fail with 'not implemented'", "tddPhase": "test", "dependencies": ["P0-004"]}, + {"id": "P0-006", "content": "Implement JWT service with access/refresh token support", "priority": "P0", "verificationCriteria": "npm test -- --grep='JWT' passes", "tddPhase": "impl", "dependencies": ["P0-005"]}, + {"id": "P1-001", "content": "Write integration tests for login flow (valid creds, invalid, locked account)", "priority": "P1", "verificationCriteria": "Integration tests exist, fail until endpoint implemented", "tddPhase": "test", "dependencies": ["P0-006"]}, + {"id": "P1-002", "content": "Implement login endpoint with rate limiting and audit logging", "priority": "P1", "verificationCriteria": "All login tests pass, endpoint returns 200/401 correctly", "tddPhase": "impl", "dependencies": ["P1-001"]}, + {"id": "P1-003", "content": "Run full test suite and verify all tests pass", "priority": "P1", "verificationCriteria": "npm test exits with code 0, coverage > 80%", "tddPhase": "verify", "dependencies": ["P1-002"]} +] + +## CRITICAL RULES +1. EVERY task MUST have verificationCriteria - this is non-negotiable! +2. EVERY implementation step should have a corresponding test step BEFORE it +3. Use tddPhase: "test" for writing tests, "impl" for implementation +4. Dependencies must form a valid DAG - no cycles +5. Be SPECIFIC - not "Add tests" but "Write tests for X covering Y and Z" +6. End with verification that ALL original requirements are met +7. Use P0 for foundation and core features, P1 for required work, P2 for nice-to-have + +NOW: Generate the implementation plan for the task above. Think step by step.`; + + // Create temporary session for the AI call using Opus 4.5 for deep reasoning + const session = new Session({ + workingDir: process.cwd(), + mux: ctx.mux, + useMux: false, // No mux needed for one-shot + mode: 'claude', + }); + + // Use configured model for plan generation, falling back to opus + const planModelConfig = await ctx.getModelConfig(); + const modelToUse = planModelConfig?.agentTypeOverrides?.implement || planModelConfig?.defaultModel || 'opus'; + + try { + const { result, cost } = await session.runPrompt(prompt, { model: modelToUse }); + + // Parse JSON from result + const jsonMatch = result.match(/\[[\s\S]*\]/); + if (!jsonMatch) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Failed to parse plan - no JSON array found'); + } + + let items: PlanItem[]; + try { + const parsed = JSON.parse(jsonMatch[0]); + if (!Array.isArray(parsed)) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Invalid response - expected array'); + } + + // Validate and normalize items with enhanced fields + items = parsed.map((item: unknown, idx: number) => { + if (typeof item !== 'object' || item === null) { + return { + id: `task-${idx}`, + content: `Step ${idx + 1}`, + priority: null, + verificationCriteria: 'Task completed successfully', + status: 'pending' as const, + attempts: 0, + version: 1, + }; + } + const obj = item as Record; + const content = typeof obj.content === 'string' ? obj.content.slice(0, 200) : `Step ${idx + 1}`; + let priority: 'P0' | 'P1' | 'P2' | null = null; + if (obj.priority === 'P0' || obj.priority === 'P1' || obj.priority === 'P2') { + priority = obj.priority; + } + + // Parse tddPhase + let tddPhase: 'setup' | 'test' | 'impl' | 'verify' | undefined; + if ( + obj.tddPhase === 'setup' || + obj.tddPhase === 'test' || + obj.tddPhase === 'impl' || + obj.tddPhase === 'verify' + ) { + tddPhase = obj.tddPhase; + } + + return { + id: obj.id ? String(obj.id) : `task-${idx}`, + content, + priority, + verificationCriteria: + typeof obj.verificationCriteria === 'string' ? obj.verificationCriteria : 'Task completed successfully', + tddPhase, + dependencies: Array.isArray(obj.dependencies) ? obj.dependencies.map(String) : [], + status: 'pending' as const, + attempts: 0, + version: 1, + }; + }); + // No artificial limit - let Claude generate what's needed + } catch (parseErr) { + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + 'Failed to parse plan JSON: ' + getErrorMessage(parseErr) + ); + } + + return { + success: true, + data: { items, costUsd: cost }, + }; + } catch (err) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Plan generation failed: ' + getErrorMessage(err)); + } finally { + // Clean up the temporary session + try { + await session.stop(); + } catch { + // Ignore cleanup errors + } + } + }); + + // Generate detailed implementation plan using subagent orchestration + // This spawns multiple specialist subagents in parallel for thorough analysis + app.post('/api/generate-plan-detailed', async (req): Promise => { + const gpdResult = GeneratePlanDetailedSchema.safeParse(req.body); + if (!gpdResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const { taskDescription, caseName } = gpdResult.data; + + // Determine output directory for saving wizard results + let outputDir: string | undefined; + if (caseName) { + const casesDir = join(homedir(), 'codeman-cases'); + const casePath = join(casesDir, caseName); + // Security: Path traversal protection - use relative path check + const resolvedCase = resolve(casePath); + const resolvedBase = resolve(casesDir); + const relPath = relative(resolvedBase, resolvedCase); + if (!relPath.startsWith('..') && !isAbsolute(relPath) && existsSync(casePath)) { + outputDir = join(casePath, 'ralph-wizard'); + + // Clear old ralph-wizard directory to ensure fresh prompts for each generation + // This prevents stale prompts from previous runs being shown when clicking on agents + if (existsSync(outputDir)) { + try { + rmSync(outputDir, { recursive: true, force: true }); + console.log(`[API] Cleared old ralph-wizard directory: ${outputDir}`); + } catch (err) { + console.warn(`[API] Failed to clear ralph-wizard directory:`, err); + } + } + } + } + + const detailedModelConfig = await ctx.getModelConfig(); + const orchestrator = new PlanOrchestrator(ctx.mux, process.cwd(), outputDir, detailedModelConfig ?? undefined); + + // Store orchestrator for potential cancellation via API (not on disconnect) + // Plan generation continues even if browser disconnects - only explicit cancel stops it + const orchestratorId = `plan-${Date.now()}`; + ctx.activePlanOrchestrators.set(orchestratorId, orchestrator); + + // Broadcast the orchestrator ID so frontend can cancel if needed + ctx.broadcast('plan:started', { orchestratorId }); + + // Track progress for SSE updates + const progressUpdates: Array<{ phase: string; detail: string; timestamp: number }> = []; + const onProgress = (phase: string, detail: string) => { + const update = { phase, detail, timestamp: Date.now() }; + progressUpdates.push(update); + // Broadcast progress to connected clients + ctx.broadcast('plan:progress', update); + }; + + // Broadcast plan subagent events for UI visibility + const onSubagent = (event: { + type: string; + agentId: string; + agentType: string; + model: string; + status: string; + detail?: string; + itemCount?: number; + durationMs?: number; + error?: string; + }) => { + ctx.broadcast('plan:subagent', event); + }; + + try { + const result: DetailedPlanResult = await orchestrator.generateDetailedPlan( + taskDescription, + onProgress, + onSubagent + ); + + // Clean up orchestrator from active map + ctx.activePlanOrchestrators.delete(orchestratorId); + ctx.broadcast('plan:completed', { orchestratorId, success: result.success }); + + if (!result.success) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, result.error || 'Plan generation failed'); + } + + return { + success: true, + data: { + items: result.items, + costUsd: result.costUsd, + metadata: result.metadata, + progressLog: progressUpdates, + orchestratorId, + }, + }; + } catch (err) { + // Clean up on error too + ctx.activePlanOrchestrators.delete(orchestratorId); + ctx.broadcast('plan:completed', { + orchestratorId, + success: false, + error: getErrorMessage(err), + }); + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + 'Detailed plan generation failed: ' + getErrorMessage(err) + ); + } + }); + + // Cancel active plan generation + app.post('/api/cancel-plan-generation', async (req): Promise => { + const cpResult = CancelPlanSchema.safeParse(req.body); + if (!cpResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const { orchestratorId } = cpResult.data; + + // If specific orchestrator ID provided, cancel just that one + if (orchestratorId) { + const orchestrator = ctx.activePlanOrchestrators.get(orchestratorId); + if (!orchestrator) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Plan generation not found or already completed'); + } + console.log(`[API] Cancelling plan generation ${orchestratorId}`); + await orchestrator.cancel(); + ctx.activePlanOrchestrators.delete(orchestratorId); + ctx.broadcast('plan:cancelled', { orchestratorId }); + return { success: true, data: { cancelled: orchestratorId } }; + } + + // Otherwise cancel all active plan generations + const cancelled: string[] = []; + for (const [id, orchestrator] of ctx.activePlanOrchestrators) { + console.log(`[API] Cancelling plan generation ${id}`); + await orchestrator.cancel(); + cancelled.push(id); + ctx.broadcast('plan:cancelled', { orchestratorId: id }); + } + ctx.activePlanOrchestrators.clear(); + + return { success: true, data: { cancelled } }; + }); + + // ============ Plan Management Endpoints ============ + // These endpoints support runtime plan adaptation with checkpoints, failure tracking, and versioning + + // Update a specific plan task (status, attempts, errors) + app.patch('/api/sessions/:id/plan/task/:taskId', async (req) => { + const { id, taskId } = req.params as { id: string; taskId: string }; + const session = findSessionOrFail(ctx, id); + + const tracker = session.ralphTracker; + if (!tracker) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); + } + + const ptuResult = PlanTaskUpdateSchema.safeParse(req.body); + if (!ptuResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const update = ptuResult.data as { + status?: 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked'; + error?: string; + incrementAttempts?: boolean; + }; + + const result = tracker.updatePlanTask(taskId, update); + if (!result.success) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, result.error || 'Task not found'); + } + + ctx.broadcast('session:planTaskUpdate', { sessionId: id, taskId, update: result.task }); + return { success: true, data: result.task }; + }); + + // Trigger a checkpoint review (at iterations 5, 10, 20, etc.) + app.post('/api/sessions/:id/plan/checkpoint', async (req) => { + const { id } = req.params as { id: string }; + const session = findSessionOrFail(ctx, id); + + const tracker = session.ralphTracker; + if (!tracker) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); + } + + const checkpoint = tracker.generateCheckpointReview(); + ctx.broadcast('session:planCheckpoint', { sessionId: id, checkpoint }); + return { success: true, data: checkpoint }; + }); + + // Get plan version history + app.get('/api/sessions/:id/plan/history', async (req) => { + const { id } = req.params as { id: string }; + const session = findSessionOrFail(ctx, id); + + const tracker = session.ralphTracker; + if (!tracker) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); + } + + return { success: true, data: tracker.getPlanHistory() }; + }); + + // Rollback to a previous plan version + app.post('/api/sessions/:id/plan/rollback/:version', async (req) => { + const { id, version } = req.params as { id: string; version: string }; + const session = findSessionOrFail(ctx, id); + + const tracker = session.ralphTracker; + if (!tracker) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); + } + + const result = tracker.rollbackToVersion(parseInt(version, 10)); + if (!result.success) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, result.error || 'Version not found'); + } + + ctx.broadcast('session:planRollback', { sessionId: id, version: parseInt(version, 10) }); + return { success: true, data: result.plan }; + }); + + // Add a new task to the plan (for runtime adaptation) + app.post('/api/sessions/:id/plan/task', async (req) => { + const { id } = req.params as { id: string }; + const session = findSessionOrFail(ctx, id); + + const tracker = session.ralphTracker; + if (!tracker) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); + } + + const ptaResult = PlanTaskAddSchema.safeParse(req.body); + if (!ptaResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const task = ptaResult.data; + + const result = tracker.addPlanTask(task); + ctx.broadcast('session:planTaskAdded', { sessionId: id, task: result.task }); + return { success: true, data: result.task }; + }); +} diff --git a/src/web/routes/push-routes.ts b/src/web/routes/push-routes.ts new file mode 100644 index 00000000..9da9219d --- /dev/null +++ b/src/web/routes/push-routes.ts @@ -0,0 +1,55 @@ +/** + * @fileoverview Push notification routes. + * Manages VAPID keys, push subscriptions, and preference updates. + */ + +import { FastifyInstance } from 'fastify'; +import { v4 as uuidv4 } from 'uuid'; +import { ApiErrorCode, createErrorResponse } from '../../types.js'; +import { PushSubscribeSchema, PushPreferencesUpdateSchema } from '../schemas.js'; +import type { InfraPort } from '../ports/index.js'; + +export function registerPushRoutes(app: FastifyInstance, ctx: InfraPort): void { + app.get('/api/push/vapid-key', async () => { + return { success: true, data: { publicKey: ctx.pushStore.getPublicKey() } }; + }); + + app.post('/api/push/subscribe', async (req) => { + const result = PushSubscribeSchema.safeParse(req.body); + if (!result.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); + } + const { endpoint, keys, userAgent, pushPreferences } = result.data; + const record = ctx.pushStore.addSubscription({ + id: uuidv4(), + endpoint, + keys, + userAgent: userAgent ?? req.headers['user-agent'] ?? '', + createdAt: Date.now(), + pushPreferences: pushPreferences ?? {}, + }); + return { success: true, data: { id: record.id } }; + }); + + app.put('/api/push/subscribe/:id', async (req) => { + const { id } = req.params as { id: string }; + const result = PushPreferencesUpdateSchema.safeParse(req.body); + if (!result.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); + } + const updated = ctx.pushStore.updatePreferences(id, result.data.pushPreferences); + if (!updated) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found'); + } + return { success: true }; + }); + + app.delete('/api/push/subscribe/:id', async (req) => { + const { id } = req.params as { id: string }; + const removed = ctx.pushStore.removeSubscription(id); + if (!removed) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found'); + } + return { success: true }; + }); +} diff --git a/src/web/routes/ralph-routes.ts b/src/web/routes/ralph-routes.ts new file mode 100644 index 00000000..f809d0a6 --- /dev/null +++ b/src/web/routes/ralph-routes.ts @@ -0,0 +1,537 @@ +/** + * @fileoverview Ralph/todo-related routes. + * Ralph tracker config, circuit breaker, fix plan CRUD, ralph prompt writing, + * and the Ralph Loop start endpoint (autonomous task execution). + */ + +import { FastifyInstance } from 'fastify'; +import { join, dirname, resolve, relative, isAbsolute } from 'node:path'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js'; +import { Session } from '../../session.js'; +import { RespawnController } from '../../respawn-controller.js'; +import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js'; +import { autoConfigureRalph } from '../route-helpers.js'; +import { writeHooksConfig } from '../../hooks-config.js'; +import { generateClaudeMd } from '../../templates/claude-md.js'; +import { getLifecycleLog } from '../../session-lifecycle-log.js'; +import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js'; +import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js'; + +const casesDir = join(homedir(), 'codeman-cases'); +const settingsPath = join(homedir(), '.codeman', 'settings.json'); + +export function registerRalphRoutes( + app: FastifyInstance, + ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort +): void { + // Configure Ralph tracker for a session + app.post('/api/sessions/:id/ralph-config', async (req) => { + const { id } = req.params as { id: string }; + const ralphResult = RalphConfigSchema.safeParse(req.body); + if (!ralphResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = ralphResult.data as { + enabled?: boolean; + completionPhrase?: string; + maxIterations?: number; + reset?: boolean | 'full'; + disableAutoEnable?: boolean; + }; + const session = ctx.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + // Ralph tracker is not supported for opencode sessions + if (session.mode === 'opencode') { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Ralph tracker is not supported for opencode sessions'); + } + + // Handle reset first (before other config) + if (reset) { + if (reset === 'full') { + session.ralphTracker.fullReset(); + } else { + session.ralphTracker.reset(); + } + } + + // Configure auto-enable behavior + if (disableAutoEnable !== undefined) { + if (disableAutoEnable) { + session.ralphTracker.disableAutoEnable(); + } else { + session.ralphTracker.enableAutoEnable(); + } + } + + // Enable/disable the tracker + if (enabled !== undefined) { + if (enabled) { + session.ralphTracker.enable(); + // Allow re-enabling on restart if user explicitly enabled + session.ralphTracker.enableAutoEnable(); + } else { + session.ralphTracker.disable(); + // Prevent re-enabling on restart when user explicitly disabled + session.ralphTracker.disableAutoEnable(); + } + // Persist Ralph enabled state + ctx.mux.updateRalphEnabled(id, enabled); + } + + // Configure the Ralph tracker + if (completionPhrase !== undefined) { + // Start loop with completion phrase to set it up for watching + if (completionPhrase) { + session.ralphTracker.startLoop(completionPhrase, maxIterations || undefined); + } + } + + if (maxIterations !== undefined) { + session.ralphTracker.setMaxIterations(maxIterations || null); + } + + // Persist and broadcast the update + ctx.persistSessionState(session); + ctx.broadcast('session:ralphLoopUpdate', { + sessionId: id, + state: session.ralphLoopState, + }); + + return { success: true }; + }); + + // Reset circuit breaker for Ralph tracker + app.post('/api/sessions/:id/ralph-circuit-breaker/reset', async (req) => { + const { id } = req.params as { id: string }; + const session = ctx.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + session.ralphTracker.resetCircuitBreaker(); + return { success: true }; + }); + + // Get Ralph status block and circuit breaker state + app.get('/api/sessions/:id/ralph-status', async (req) => { + const { id } = req.params as { id: string }; + const session = ctx.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + return { + success: true, + data: { + lastStatusBlock: session.ralphTracker.lastStatusBlock, + circuitBreaker: session.ralphTracker.circuitBreakerStatus, + cumulativeStats: session.ralphTracker.cumulativeStats, + exitGateMet: session.ralphTracker.exitGateMet, + }, + }; + }); + + // Generate @fix_plan.md content from todos + app.get('/api/sessions/:id/fix-plan', async (req) => { + const { id } = req.params as { id: string }; + const session = ctx.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + const content = session.ralphTracker.generateFixPlanMarkdown(); + return { + success: true, + data: { + content, + todoCount: session.ralphTracker.todos.length, + }, + }; + }); + + // Import todos from @fix_plan.md content + app.post('/api/sessions/:id/fix-plan/import', async (req) => { + const { id } = req.params as { id: string }; + const importResult = FixPlanImportSchema.safeParse(req.body); + if (!importResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const { content } = importResult.data; + const session = ctx.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + const importedCount = session.ralphTracker.importFixPlanMarkdown(content); + ctx.persistSessionState(session); + + return { + success: true, + data: { + importedCount, + todos: session.ralphTracker.todos, + }, + }; + }); + + // Write @fix_plan.md to session's working directory + app.post('/api/sessions/:id/fix-plan/write', async (req) => { + const { id } = req.params as { id: string }; + const session = ctx.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + const workingDir = session.workingDir; + if (!workingDir) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Session has no working directory'); + } + + const content = session.ralphTracker.generateFixPlanMarkdown(); + const filePath = join(workingDir, '@fix_plan.md'); + + try { + await fs.writeFile(filePath, content, 'utf-8'); + return { + success: true, + data: { + filePath, + todoCount: session.ralphTracker.todos.length, + }, + }; + } catch (error) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to write file: ${error}`); + } + }); + + // Read @fix_plan.md from session's working directory and import + app.post('/api/sessions/:id/fix-plan/read', async (req) => { + const { id } = req.params as { id: string }; + const session = ctx.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + const workingDir = session.workingDir; + if (!workingDir) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Session has no working directory'); + } + + const filePath = join(workingDir, '@fix_plan.md'); + + try { + const content = await fs.readFile(filePath, 'utf-8'); + const importedCount = session.ralphTracker.importFixPlanMarkdown(content); + ctx.persistSessionState(session); + + return { + success: true, + data: { + filePath, + importedCount, + todos: session.ralphTracker.todos, + }, + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return createErrorResponse(ApiErrorCode.NOT_FOUND, '@fix_plan.md not found in working directory'); + } + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${error}`); + } + }); + + // Write Ralph prompt to file in session's working directory + // This avoids mux input escaping issues with long multi-line prompts + app.post('/api/sessions/:id/ralph-prompt/write', async (req) => { + const { id } = req.params as { id: string }; + const promptResult = RalphPromptWriteSchema.safeParse(req.body); + if (!promptResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const { content } = promptResult.data; + const session = ctx.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + const workingDir = session.workingDir; + if (!workingDir) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Session has no working directory'); + } + + const filePath = join(workingDir, '@ralph_prompt.md'); + + try { + await fs.writeFile(filePath, content, 'utf-8'); + return { + success: true, + data: { + filePath, + contentLength: content.length, + }, + }; + } catch (error) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to write file: ${error}`); + } + }); + + // Start a Ralph Loop — creates a new session with autonomous cycling + app.post('/api/ralph-loop/start', async (req): Promise => { + // Prevent unbounded session creation + if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) { + return createErrorResponse( + ApiErrorCode.SESSION_BUSY, + `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.` + ); + } + + const rlResult = RalphLoopStartSchema.safeParse(req.body); + if (!rlResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, rlResult.error.issues[0]?.message ?? 'Validation failed'); + } + const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems } = rlResult.data; + + const casePath = join(casesDir, caseName); + + // Security: Path traversal protection + const rlResolvedPath = resolve(casePath); + const rlResolvedBase = resolve(casesDir); + const rlRelPath = relative(rlResolvedBase, rlResolvedPath); + if (rlRelPath.startsWith('..') || isAbsolute(rlRelPath)) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path'); + } + + // Create case folder if it doesn't exist (reuse quick-start logic) + if (!existsSync(casePath)) { + try { + mkdirSync(casePath, { recursive: true }); + mkdirSync(join(casePath, 'src'), { recursive: true }); + const templatePath = await ctx.getDefaultClaudeMdPath(); + const claudeMd = generateClaudeMd(caseName, '', templatePath); + writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd); + await writeHooksConfig(casePath); + ctx.broadcast('case:created', { name: caseName, path: casePath }); + } catch (err) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`); + } + } + + // Create session + const niceConfig = await ctx.getGlobalNiceConfig(); + const rlModelConfig = await ctx.getModelConfig(); + const rlClaudeModeConfig = await ctx.getClaudeModeConfig(); + const session = new Session({ + workingDir: casePath, + mux: ctx.mux, + useMux: true, + mode: 'claude', + niceConfig, + model: rlModelConfig?.defaultModel, + claudeMode: rlClaudeModeConfig.claudeMode, + allowedTools: rlClaudeModeConfig.allowedTools, + }); + + // Configure Ralph tracker + autoConfigureRalph(session, casePath, ctx); + if (!session.ralphTracker.enabled) { + session.ralphTracker.enable(); + session.ralphTracker.enableAutoEnable(); + } + session.ralphTracker.startLoop(completionPhrase, maxIterations ?? undefined); + + // Build fix_plan markdown from plan items if provided + const enabledItems = planItems?.filter((i) => i.enabled) ?? []; + let planContent = ''; + if (enabledItems.length > 0) { + const p0 = enabledItems.filter((i) => i.priority === 'P0'); + const p1 = enabledItems.filter((i) => i.priority === 'P1'); + const p2 = enabledItems.filter((i) => i.priority === 'P2'); + const noPri = enabledItems.filter((i) => !i.priority); + planContent = '# Implementation Plan\n\n'; + planContent += `Generated: ${new Date().toISOString().slice(0, 10)}\n\n`; + if (p0.length > 0) { + planContent += '## Critical Path (P0)\n\n'; + p0.forEach((i) => { + planContent += `- [ ] ${i.content}\n`; + }); + planContent += '\n'; + } + if (p1.length > 0) { + planContent += '## Standard (P1)\n\n'; + p1.forEach((i) => { + planContent += `- [ ] ${i.content}\n`; + }); + planContent += '\n'; + } + if (p2.length > 0) { + planContent += '## Nice-to-Have (P2)\n\n'; + p2.forEach((i) => { + planContent += `- [ ] ${i.content}\n`; + }); + planContent += '\n'; + } + if (noPri.length > 0) { + planContent += '## Tasks\n\n'; + noPri.forEach((i) => { + planContent += `- [ ] ${i.content}\n`; + }); + planContent += '\n'; + } + + // Import into tracker and write to disk + session.ralphTracker.importFixPlanMarkdown(planContent); + const fixPlanPath = join(casePath, '@fix_plan.md'); + writeFileSync(fixPlanPath, planContent, 'utf-8'); + } + + // Build full prompt + const hasPlan = enabledItems.length > 0; + let fullPrompt = taskDescription + '\n\n---\n\n'; + if (hasPlan) { + fullPrompt += '## Task Plan\n\n'; + fullPrompt += 'A task plan has been written to `@fix_plan.md`. Use this to track progress:\n'; + fullPrompt += '- Reference the plan at the start of each iteration\n'; + fullPrompt += '- Update task checkboxes as you complete items\n'; + fullPrompt += '- Work through items in priority order (P0 > P1 > P2)\n\n'; + } + fullPrompt += '## Iteration Protocol\n\n'; + fullPrompt += 'This is an autonomous loop. Files from previous iterations persist. On each iteration:\n'; + fullPrompt += '1. Check what work has already been done\n'; + fullPrompt += '2. Make incremental progress toward completion\n'; + fullPrompt += '3. Commit meaningful changes with descriptive messages\n\n'; + fullPrompt += '## Verification\n\n'; + fullPrompt += 'After each significant change:\n'; + fullPrompt += '- Run tests to verify (npm test, pytest, etc.)\n'; + fullPrompt += '- Check for type/lint errors if applicable\n'; + fullPrompt += '- If tests fail, read the error, fix it, and retry\n\n'; + fullPrompt += '## Completion Criteria\n\n'; + fullPrompt += `Output \`${completionPhrase}\` when ALL of the following are true:\n`; + fullPrompt += '- All requirements from the task description are implemented\n'; + fullPrompt += '- All tests pass\n'; + fullPrompt += '- Changes are committed\n\n'; + fullPrompt += '## If Stuck\n\n'; + fullPrompt += 'If you encounter the same error for 3+ iterations:\n'; + fullPrompt += "1. Document what you've tried\n"; + fullPrompt += '2. Identify the specific blocker\n'; + fullPrompt += '3. Try an alternative approach\n'; + fullPrompt += '4. If truly blocked, output `BLOCKED` with an explanation\n'; + + // Write prompt to file + const promptPath = join(casePath, '@ralph_prompt.md'); + writeFileSync(promptPath, fullPrompt, 'utf-8'); + + // Register session + (ctx.sessions as Map).set(session.id, session); + ctx.store.incrementSessionsCreated(); + ctx.persistSessionState(session); + await ctx.setupSessionListeners(session); + getLifecycleLog().log({ + event: 'created', + sessionId: session.id, + name: session.name, + reason: 'ralph_loop_start', + }); + ctx.broadcast('session:created', ctx.getSessionStateWithRespawn(session)); + + // Start interactive mode + try { + await session.startInteractive(); + getLifecycleLog().log({ + event: 'started', + sessionId: session.id, + name: session.name, + mode: 'claude', + }); + ctx.broadcast('session:interactive', { id: session.id, mode: 'claude' }); + ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) }); + } catch (err) { + await ctx.cleanupSession(session.id, true, 'ralph_loop_start_error'); + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + } + + // Enable respawn if requested + if (enableRespawn) { + const ralphUpdatePrompt = + 'Before /clear: Update CLAUDE.md with discoveries and notes, mark completed tasks in @fix_plan.md, write a brief progress summary to a file so the next iteration can continue seamlessly.'; + const ralphKickstartPrompt = `You are in a Ralph Wiggum loop. Read @fix_plan.md for task status, continue on the next uncompleted task, output ${completionPhrase} when ALL tasks are complete.`; + const controller = new RespawnController(session, { + updatePrompt: ralphUpdatePrompt, + sendClear: true, + sendInit: true, + kickstartPrompt: ralphKickstartPrompt, + }); + ctx.respawnControllers.set(session.id, controller); + ctx.setupRespawnListeners(session.id, controller); + controller.start(); + ctx.saveRespawnConfig(session.id, controller.getConfig()); + ctx.persistSessionState(session); + ctx.broadcast('respawn:started', { + sessionId: session.id, + status: controller.getStatus(), + }); + } + + // Save lastUsedCase + try { + let settings: Record = {}; + try { + settings = JSON.parse(await fs.readFile(settingsPath, 'utf-8')); + } catch { + /* ignore */ + } + settings.lastUsedCase = caseName; + const dir = dirname(settingsPath); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)).catch(() => {}); + } catch { + /* non-critical */ + } + + const sessionId = session.id; + + // Async: poll for CLI readiness, then send prompt + setImmediate(() => { + const pollReady = async () => { + for (let attempt = 0; attempt < 60; attempt++) { + await new Promise((r) => setTimeout(r, 500)); + const s = ctx.sessions.get(sessionId); + if (!s) return; // session was deleted + // Check terminal output for prompt indicator + const termBuf = s.getTerminalBuffer().slice(-2048); + if (termBuf.includes('\u276F') || termBuf.includes('tokens')) { + break; + } + } + // Small extra delay for CLI to settle + await new Promise((r) => setTimeout(r, 2000)); + const s = ctx.sessions.get(sessionId); + if (!s) return; + try { + await s.writeViaMux('Read @ralph_prompt.md and follow the instructions. Start working immediately.\r'); + } catch (err) { + console.warn(`[RalphLoop] Failed to send prompt to session ${sessionId}:`, getErrorMessage(err)); + } + }; + pollReady().catch((err) => console.error('[RalphLoop] pollReady error:', err)); + }); + + return { + success: true, + data: { sessionId, caseName }, + }; + }); +} diff --git a/src/web/routes/respawn-routes.ts b/src/web/routes/respawn-routes.ts new file mode 100644 index 00000000..801fdc36 --- /dev/null +++ b/src/web/routes/respawn-routes.ts @@ -0,0 +1,312 @@ +/** + * @fileoverview Respawn management routes. + * Provides respawn status, config CRUD, start/stop, interactive-respawn, and enable/disable. + */ + +import { FastifyInstance } from 'fastify'; +import { ApiErrorCode, createErrorResponse, getErrorMessage, type PersistedRespawnConfig } from '../../types.js'; +import { RespawnController, type RespawnConfig } from '../../respawn-controller.js'; +import { RespawnConfigSchema, InteractiveRespawnSchema, RespawnEnableSchema } from '../schemas.js'; +import { findSessionOrFail, autoConfigureRalph } from '../route-helpers.js'; +import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js'; +import { getLifecycleLog } from '../../session-lifecycle-log.js'; + +/** No-op EventPort used to suppress broadcasts during pre-start ralph configuration. */ +const noopEventPort: EventPort = { + broadcast: () => {}, + sendPushNotifications: () => {}, + batchTerminalData: () => {}, + broadcastSessionStateDebounced: () => {}, + batchTaskUpdate: () => {}, +}; + +export function registerRespawnRoutes( + app: FastifyInstance, + ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort +): void { + // Get respawn status for a session + app.get('/api/sessions/:id/respawn', async (req) => { + const { id } = req.params as { id: string }; + const controller = ctx.respawnControllers.get(id); + + if (!controller) { + return { enabled: false, status: null }; + } + + return { + enabled: true, + ...controller.getStatus(), + }; + }); + + // Get respawn config (from running controller or pre-saved) + app.get('/api/sessions/:id/respawn/config', async (req) => { + const { id } = req.params as { id: string }; + const controller = ctx.respawnControllers.get(id); + + if (controller) { + return { success: true, config: controller.getConfig(), active: true }; + } + + // Return pre-saved config from mux-sessions.json + const preConfig = ctx.mux.getSession(id)?.respawnConfig; + if (preConfig) { + return { success: true, config: preConfig, active: false }; + } + + return { success: true, config: null, active: false }; + }); + + // Start respawn controller for a session + app.post('/api/sessions/:id/respawn/start', async (req) => { + const { id } = req.params as { id: string }; + let body: Partial | undefined; + if (req.body) { + const result = RespawnConfigSchema.safeParse(req.body); + if (!result.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid respawn config'); + } + body = result.data as Partial; + } + const session = findSessionOrFail(ctx, id); + + // Respawn is not supported for opencode sessions + if (session.mode === 'opencode') { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions'); + } + + // Create or get existing controller + let controller = ctx.respawnControllers.get(id); + if (!controller) { + // Merge request body with pre-saved config from mux-sessions.json + const preConfig = ctx.mux.getSession(id)?.respawnConfig; + const config = body || preConfig ? { ...preConfig, ...body } : undefined; + controller = new RespawnController(session, config); + ctx.respawnControllers.set(id, controller); + ctx.setupRespawnListeners(id, controller); + } else if (body) { + controller.updateConfig(body); + } + + controller.start(); + + // Persist respawn config to mux session and state.json + ctx.saveRespawnConfig(id, controller.getConfig()); + ctx.persistSessionState(session); + + ctx.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() }); + + return { success: true, status: controller.getStatus() }; + }); + + // Stop respawn controller for a session + app.post('/api/sessions/:id/respawn/stop', async (req) => { + const { id } = req.params as { id: string }; + const controller = ctx.respawnControllers.get(id); + + if (!controller) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Respawn controller not found'); + } + + controller.stop(); + + // Remove controller from map so persistSessionState doesn't save respawnEnabled: true + ctx.respawnControllers.delete(id); + + // Clear any timed respawn + const timerInfo = ctx.respawnTimers.get(id); + if (timerInfo) { + clearTimeout(timerInfo.timer); + ctx.respawnTimers.delete(id); + } + + // Clear persisted respawn config + ctx.mux.clearRespawnConfig(id); + + // Update state.json (respawnConfig removed) + const session = ctx.sessions.get(id); + if (session) { + ctx.persistSessionState(session); + } + + ctx.broadcast('respawn:stopped', { sessionId: id }); + + return { success: true }; + }); + + // Update respawn configuration (works with or without running controller) + app.put('/api/sessions/:id/respawn/config', async (req) => { + const { id } = req.params as { id: string }; + // Validate respawn config to prevent arbitrary field injection + const parseResult = RespawnConfigSchema.safeParse(req.body); + if (!parseResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid respawn config: ${parseResult.error.message}`); + } + const config = parseResult.data as Partial; + const session = findSessionOrFail(ctx, id); + + const controller = ctx.respawnControllers.get(id); + + if (controller) { + // Update running controller + controller.updateConfig(config); + ctx.saveRespawnConfig(id, controller.getConfig()); + ctx.persistSessionState(session); + ctx.broadcast('respawn:configUpdated', { sessionId: id, config: controller.getConfig() }); + return { success: true, config: controller.getConfig() }; + } + + // No controller running - save as pre-config for when respawn starts + const existing = ctx.mux.getSession(id); + const currentConfig = existing?.respawnConfig; + const merged: PersistedRespawnConfig = { + enabled: config.enabled ?? currentConfig?.enabled ?? false, + idleTimeoutMs: config.idleTimeoutMs ?? currentConfig?.idleTimeoutMs ?? 10000, + updatePrompt: config.updatePrompt ?? currentConfig?.updatePrompt ?? 'update all the docs and CLAUDE.md', + interStepDelayMs: config.interStepDelayMs ?? currentConfig?.interStepDelayMs ?? 1000, + sendClear: config.sendClear ?? currentConfig?.sendClear ?? true, + sendInit: config.sendInit ?? currentConfig?.sendInit ?? true, + kickstartPrompt: config.kickstartPrompt ?? currentConfig?.kickstartPrompt, + autoAcceptPrompts: config.autoAcceptPrompts ?? currentConfig?.autoAcceptPrompts ?? true, + autoAcceptDelayMs: config.autoAcceptDelayMs ?? currentConfig?.autoAcceptDelayMs ?? 8000, + aiIdleCheckEnabled: config.aiIdleCheckEnabled ?? currentConfig?.aiIdleCheckEnabled ?? true, + aiIdleCheckModel: config.aiIdleCheckModel ?? currentConfig?.aiIdleCheckModel ?? 'claude-opus-4-5-20251101', + aiIdleCheckMaxContext: config.aiIdleCheckMaxContext ?? currentConfig?.aiIdleCheckMaxContext ?? 16000, + aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs ?? 90000, + aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs ?? 180000, + aiPlanCheckEnabled: config.aiPlanCheckEnabled ?? currentConfig?.aiPlanCheckEnabled ?? true, + aiPlanCheckModel: config.aiPlanCheckModel ?? currentConfig?.aiPlanCheckModel ?? 'claude-opus-4-5-20251101', + aiPlanCheckMaxContext: config.aiPlanCheckMaxContext ?? currentConfig?.aiPlanCheckMaxContext ?? 8000, + aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs ?? currentConfig?.aiPlanCheckTimeoutMs ?? 60000, + aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs ?? 30000, + durationMinutes: currentConfig?.durationMinutes, + }; + ctx.mux.updateRespawnConfig(id, merged); + ctx.persistSessionState(session); + ctx.broadcast('respawn:configUpdated', { sessionId: id, config: merged }); + return { success: true, config: merged }; + }); + + // Start interactive session WITH respawn enabled + app.post('/api/sessions/:id/interactive-respawn', async (req) => { + const { id } = req.params as { id: string }; + const irResult = req.body ? InteractiveRespawnSchema.safeParse(req.body) : { success: true as const, data: {} }; + if (!irResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const body = irResult.data as { + respawnConfig?: Partial; + durationMinutes?: number; + }; + const session = findSessionOrFail(ctx, id); + + if (session.isBusy()) { + return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy'); + } + + // Respawn is not supported for opencode sessions + if (session.mode === 'opencode') { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions'); + } + + try { + // Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user) + if (ctx.store.getConfig().ralphEnabled && !session.ralphTracker.autoEnableDisabled) { + autoConfigureRalph(session, session.workingDir, noopEventPort); + if (!session.ralphTracker.enabled) { + session.ralphTracker.enable(); + } + } + + // Start interactive session + await session.startInteractive(); + getLifecycleLog().log({ + event: 'started', + sessionId: id, + name: session.name, + mode: session.mode, + reason: 'interactive_respawn', + }); + ctx.broadcast('session:interactive', { id }); + ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) }); + + // Create and start respawn controller + const controller = new RespawnController(session, body?.respawnConfig); + ctx.respawnControllers.set(id, controller); + ctx.setupRespawnListeners(id, controller); + controller.start(); + + // Set up timed stop if duration specified + if (body?.durationMinutes && body.durationMinutes > 0) { + ctx.setupTimedRespawn(id, body.durationMinutes); + } + + // Persist full session state with respawn config + ctx.persistSessionState(session); + + ctx.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() }); + + return { + success: true, + data: { + message: 'Interactive session with respawn started', + respawnStatus: controller.getStatus(), + }, + }; + } catch (err) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + } + }); + + // Enable respawn on an EXISTING interactive session + app.post('/api/sessions/:id/respawn/enable', async (req) => { + const { id } = req.params as { id: string }; + const reResult = req.body ? RespawnEnableSchema.safeParse(req.body) : { success: true as const, data: {} }; + if (!reResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const body = reResult.data as { config?: Partial; durationMinutes?: number }; + const session = findSessionOrFail(ctx, id); + + // Respawn is not supported for opencode sessions + if (session.mode === 'opencode') { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions'); + } + + // Check if session is running (has a PID) + if (!session.pid) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Session is not running. Start it first.'); + } + + // Stop existing controller if any + const existingController = ctx.respawnControllers.get(id); + if (existingController) { + existingController.stop(); + } + + // Create and start new respawn controller (merge with pre-saved config) + const preConfig = ctx.mux.getSession(id)?.respawnConfig; + const config = body?.config || preConfig ? { ...preConfig, ...body?.config } : undefined; + const controller = new RespawnController(session, config); + ctx.respawnControllers.set(id, controller); + ctx.setupRespawnListeners(id, controller); + controller.start(); + + // Set up timed stop if duration specified + if (body?.durationMinutes && body.durationMinutes > 0) { + ctx.setupTimedRespawn(id, body.durationMinutes); + } + + // Persist respawn config to mux session and state.json + ctx.saveRespawnConfig(id, controller.getConfig(), body?.durationMinutes); + ctx.persistSessionState(session); + + ctx.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() }); + + return { + success: true, + message: 'Respawn enabled on existing session', + respawnStatus: controller.getStatus(), + }; + }); +} diff --git a/src/web/routes/scheduled-routes.ts b/src/web/routes/scheduled-routes.ts new file mode 100644 index 00000000..e7afe6ed --- /dev/null +++ b/src/web/routes/scheduled-routes.ts @@ -0,0 +1,62 @@ +/** + * @fileoverview Scheduled run routes. + * CRUD operations for scheduled autonomous runs with session lifecycle management. + */ + +import { FastifyInstance } from 'fastify'; +import { statSync } from 'node:fs'; +import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js'; +import { ScheduledRunSchema } from '../schemas.js'; +import type { SessionPort, EventPort, InfraPort, ScheduledRun } from '../ports/index.js'; + +export function registerScheduledRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & InfraPort): void { + app.get('/api/scheduled', async () => { + return Array.from(ctx.scheduledRuns.values()); + }); + + app.post('/api/scheduled', async (req): Promise<{ success: boolean; run: ScheduledRun } | ApiResponse> => { + const srResult = ScheduledRunSchema.safeParse(req.body); + if (!srResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const { prompt, workingDir, durationMinutes } = srResult.data; + + // Validate workingDir exists and is a directory + if (workingDir) { + try { + const stat = statSync(workingDir); + if (!stat.isDirectory()) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory'); + } + } catch { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist'); + } + } + + const run = await ctx.startScheduledRun(prompt, workingDir || process.cwd(), durationMinutes ?? 60); + return { success: true, run }; + }); + + app.delete('/api/scheduled/:id', async (req) => { + const { id } = req.params as { id: string }; + const run = ctx.scheduledRuns.get(id); + + if (!run) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled run not found'); + } + + await ctx.stopScheduledRun(id); + return { success: true }; + }); + + app.get('/api/scheduled/:id', async (req) => { + const { id } = req.params as { id: string }; + const run = ctx.scheduledRuns.get(id); + + if (!run) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled run not found'); + } + + return run; + }); +} diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts new file mode 100644 index 00000000..50ad86da --- /dev/null +++ b/src/web/routes/session-routes.ts @@ -0,0 +1,907 @@ +/** + * @fileoverview Session management routes. + * Covers session CRUD, input/output, terminal buffer, quick-start, quick-run, + * auto-clear, auto-compact, image watcher, flicker filter, and logout. + */ + +import { FastifyInstance } from 'fastify'; +import { join, dirname, resolve, relative, isAbsolute } from 'node:path'; +import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { + ApiErrorCode, + createErrorResponse, + getErrorMessage, + type ApiResponse, + type QuickStartResponse, + type SessionColor, +} from '../../types.js'; +import { Session } from '../../session.js'; +import { + CreateSessionSchema, + SessionNameSchema, + SessionColorSchema, + RunPromptSchema, + SessionInputWithLimitSchema, + ResizeSchema, + AutoClearSchema, + AutoCompactSchema, + ImageWatcherSchema, + FlickerFilterSchema, + QuickRunSchema, + QuickStartSchema, +} from '../schemas.js'; +import { autoConfigureRalph } from '../route-helpers.js'; +import { writeHooksConfig, updateCaseEnvVars } from '../../hooks-config.js'; +import { generateClaudeMd } from '../../templates/claude-md.js'; +import { imageWatcher } from '../../image-watcher.js'; +import { getLifecycleLog } from '../../session-lifecycle-log.js'; +import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js'; +import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js'; +import { RunSummaryTracker } from '../../run-summary.js'; + +// Constants +const MAX_INPUT_LENGTH = 64 * 1024; +const MAX_TERMINAL_COLS = 500; +const MAX_TERMINAL_ROWS = 200; +const MAX_SESSION_NAME_LENGTH = 128; +const AUTH_COOKIE_NAME = 'codeman_session'; + +// Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation) +// eslint-disable-next-line no-control-regex +const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/; +// eslint-disable-next-line no-control-regex +const CTRL_L_PATTERN = /\x0c/g; +const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/; + +const casesDir = join(homedir(), 'codeman-cases'); +const settingsPath = join(homedir(), '.codeman', 'settings.json'); + +export function registerSessionRoutes( + app: FastifyInstance, + ctx: SessionPort & EventPort & ConfigPort & InfraPort +): void { + // ========== Logout ========== + + app.post('/api/logout', async (_req, reply) => { + reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' }); + return { success: true }; + }); + + // ========== Session Listing ========== + + app.get('/api/sessions', async () => { + return Array.from(ctx.sessions.values()).map((s) => ctx.getSessionStateWithRespawn(s)); + }); + + // ========== Session Creation ========== + + app.post('/api/sessions', async (req) => { + // Prevent unbounded session creation + if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) { + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached. Delete some sessions first.` + ); + } + + const result = CreateSessionSchema.safeParse(req.body); + if (!result.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); + } + const body = result.data; + const workingDir = body.workingDir || process.cwd(); + + // Validate workingDir exists and is a directory + if (body.workingDir) { + try { + const stat = statSync(workingDir); + if (!stat.isDirectory()) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory'); + } + } catch { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist'); + } + } + + // Write env overrides to .claude/settings.local.json if provided + if (body.envOverrides && Object.keys(body.envOverrides).length > 0) { + await updateCaseEnvVars(workingDir, body.envOverrides); + } + + // Check OpenCode availability if requested + if (body.mode === 'opencode') { + const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js'); + if (!isOpenCodeAvailable()) { + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash' + ); + } + } + + const globalNice = await ctx.getGlobalNiceConfig(); + const modelConfig = await ctx.getModelConfig(); + const mode = body.mode || 'claude'; + const model = + mode === 'opencode' ? body.openCodeConfig?.model : mode !== 'shell' ? modelConfig?.defaultModel : undefined; + const claudeModeConfig = await ctx.getClaudeModeConfig(); + const session = new Session({ + workingDir, + mode, + name: body.name || '', + mux: ctx.mux, + useMux: true, + niceConfig: globalNice, + model, + claudeMode: claudeModeConfig.claudeMode, + allowedTools: claudeModeConfig.allowedTools, + openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined, + }); + + (ctx.sessions as Map).set(session.id, session); + ctx.store.incrementSessionsCreated(); + ctx.persistSessionState(session); + await ctx.setupSessionListeners(session); + getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name }); + + // Use light state for broadcast + response — buffers are fetched on-demand via /terminal. + // Avoids serializing 2-3MB of terminal+text buffers per session creation. + const lightState = ctx.getSessionStateWithRespawn(session); + ctx.broadcast('session:created', lightState); + return { success: true, session: lightState }; + }); + + // ========== Rename Session ========== + + app.put('/api/sessions/:id/name', async (req) => { + const { id } = req.params as { id: string }; + const result = SessionNameSchema.safeParse(req.body); + if (!result.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const body = result.data; + const session = ctx.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH); + session.name = name; + // Also update the mux session name if applicable + ctx.mux.updateSessionName(id, session.name); + ctx.persistSessionState(session); + ctx.broadcast('session:updated', ctx.getSessionStateWithRespawn(session)); + return { success: true, name: session.name }; + }); + + // ========== Set Session Color ========== + + app.put('/api/sessions/:id/color', async (req) => { + const { id } = req.params as { id: string }; + const result = SessionColorSchema.safeParse(req.body); + if (!result.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const body = result.data; + const session = ctx.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + const validColors = ['default', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink']; + if (!validColors.includes(body.color)) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid color'); + } + + session.setColor(body.color as SessionColor); + ctx.persistSessionState(session); + ctx.broadcast('session:updated', ctx.getSessionStateWithRespawn(session)); + return { success: true, color: session.color }; + }); + + // ========== Delete Session ========== + + app.delete('/api/sessions/:id', async (req): Promise => { + const { id } = req.params as { id: string }; + const query = req.query as { killMux?: string }; + const killMux = query.killMux !== 'false'; // Default to true + + if (!ctx.sessions.has(id)) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + await ctx.cleanupSession(id, killMux, 'user_delete'); + return { success: true }; + }); + + // ========== Delete All Sessions ========== + + app.delete('/api/sessions', async (): Promise> => { + const sessionIds = Array.from(ctx.sessions.keys()); + let killed = 0; + + for (const id of sessionIds) { + if (ctx.sessions.has(id)) { + await ctx.cleanupSession(id, true, 'user_bulk_delete'); + killed++; + } + } + + return { success: true, data: { killed } }; + }); + + // ========== Get Session Detail ========== + + app.get('/api/sessions/:id', async (req) => { + const { id } = req.params as { id: string }; + const session = ctx.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + // Use light state (no full buffers) — terminal buffer available via /terminal endpoint. + // Full buffers were 2-3MB and caused slowness when polled frequently (e.g. Ralph wizard). + return ctx.getSessionStateWithRespawn(session); + }); + + // ========== Get Session Output ========== + + app.get('/api/sessions/:id/output', async (req) => { + const { id } = req.params as { id: string }; + const session = ctx.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + return { + success: true, + data: { + textOutput: session.textOutput, + messages: session.messages, + errorBuffer: session.errorBuffer, + }, + }; + }); + + // ========== Get Ralph State ========== + + app.get('/api/sessions/:id/ralph-state', async (req) => { + const { id } = req.params as { id: string }; + const session = ctx.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + return { + success: true, + data: { + loop: session.ralphLoopState, + todos: session.ralphTodos, + todoStats: session.ralphTodoStats, + }, + }; + }); + + // ========== Get Run Summary ========== + + app.get('/api/sessions/:id/run-summary', async (req) => { + const { id } = req.params as { id: string }; + const session = ctx.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + const tracker = ctx.runSummaryTrackers.get(id); + if (!tracker) { + // Create a fresh tracker if one doesn't exist (shouldn't happen normally) + const newTracker = new RunSummaryTracker(id, session.name); + ctx.runSummaryTrackers.set(id, newTracker); + return { success: true, summary: newTracker.getSummary() }; + } + + // Update session name in case it changed + tracker.setSessionName(session.name); + + return { success: true, summary: tracker.getSummary() }; + }); + + // ========== Get Active Tools ========== + + app.get('/api/sessions/:id/active-tools', async (req) => { + const { id } = req.params as { id: string }; + const session = ctx.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + return { + success: true, + data: { + tools: session.activeTools, + }, + }; + }); + + // ========== Run Prompt ========== + + app.post('/api/sessions/:id/run', async (req): Promise => { + const { id } = req.params as { id: string }; + const result = RunPromptSchema.safeParse(req.body); + if (!result.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); + } + const { prompt } = result.data; + const session = ctx.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + if (session.isBusy()) { + return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy'); + } + + // Run async, don't wait + session.runPrompt(prompt).catch((err) => { + ctx.broadcast('session:error', { id, error: err.message }); + }); + + ctx.broadcast('session:running', { id, prompt }); + return { success: true }; + }); + + // ========== Start Interactive Mode ========== + + app.post('/api/sessions/:id/interactive', async (req): Promise => { + const { id } = req.params as { id: string }; + const session = ctx.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + if (session.isBusy()) { + return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy'); + } + + try { + // Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user) + // Ralph tracker is not supported for opencode sessions + if ( + session.mode !== 'opencode' && + ctx.store.getConfig().ralphEnabled && + !session.ralphTracker.autoEnableDisabled + ) { + autoConfigureRalph(session, session.workingDir, ctx); + if (!session.ralphTracker.enabled) { + session.ralphTracker.enable(); + } + } + + await session.startInteractive(); + getLifecycleLog().log({ + event: 'started', + sessionId: id, + name: session.name, + mode: session.mode, + }); + ctx.broadcast('session:interactive', { id }); + ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) }); + + return { success: true }; + } catch (err) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + } + }); + + // ========== Start Shell Mode ========== + + app.post('/api/sessions/:id/shell', async (req): Promise => { + const { id } = req.params as { id: string }; + const session = ctx.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + if (session.isBusy()) { + return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy'); + } + + try { + await session.startShell(); + getLifecycleLog().log({ + event: 'started', + sessionId: id, + name: session.name, + mode: 'shell', + }); + ctx.broadcast('session:interactive', { id, mode: 'shell' }); + ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) }); + return { success: true }; + } catch (err) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + } + }); + + // ========== Send Input ========== + + app.post('/api/sessions/:id/input', async (req): Promise => { + const { id } = req.params as { id: string }; + const result = SessionInputWithLimitSchema.safeParse(req.body); + if (!result.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); + } + const { input, useMux } = result.data; + const session = ctx.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + const inputStr = String(input); + if (inputStr.length > MAX_INPUT_LENGTH) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + `Input exceeds maximum length (${MAX_INPUT_LENGTH} bytes)` + ); + } + + // Write input to PTY. Direct write is synchronous; writeViaMux + // (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response. + if (useMux) { + // Fire-and-forget: don't block HTTP response on tmux child process. + // Fallback to direct write on failure. + session + .writeViaMux(inputStr) + .then((ok) => { + if (!ok) { + console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`); + session.write(inputStr); + } + }) + .catch(() => { + session.write(inputStr); + }); + } else { + session.write(inputStr); + } + return { success: true }; + }); + + // ========== Resize Terminal ========== + + app.post('/api/sessions/:id/resize', async (req): Promise => { + const { id } = req.params as { id: string }; + const result = ResizeSchema.safeParse(req.body); + if (!result.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); + } + const { cols, rows } = result.data; + const session = ctx.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + // Note: Zod already validates that cols and rows are positive integers within bounds + if (cols > MAX_TERMINAL_COLS || rows > MAX_TERMINAL_ROWS) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + `Terminal dimensions exceed maximum (${MAX_TERMINAL_COLS}x${MAX_TERMINAL_ROWS})` + ); + } + + session.resize(cols, rows); + return { success: true }; + }); + + // ========== Get Terminal Buffer ========== + + // Query params: + // tail= - Only return last N bytes (faster initial load) + app.get('/api/sessions/:id/terminal', async (req) => { + const { id } = req.params as { id: string }; + const query = req.query as { tail?: string }; + const session = ctx.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + const tailBytes = query.tail ? parseInt(query.tail, 10) : 0; + const fullSize = session.terminalBufferLength; + let truncated = false; + let cleanBuffer: string; + + if (tailBytes > 0 && fullSize > tailBytes) { + // Fast path: tail from the end, skip expensive banner search on full 2MB buffer. + // Banner is near the top and gets discarded by tail anyway. + cleanBuffer = session.terminalBuffer.slice(-tailBytes); + truncated = true; + // Avoid starting mid-ANSI-escape: find first newline within the first 4KB + // and start from there. This prevents xterm.js from parsing a partial escape + // sequence which corrupts cursor position for all subsequent Ink redraws. + const firstNewline = cleanBuffer.indexOf('\n'); + if (firstNewline > 0 && firstNewline < 4096) { + cleanBuffer = cleanBuffer.slice(firstNewline + 1); + } + } else { + // Full buffer: clean junk before actual Claude content + cleanBuffer = session.terminalBuffer; + + // Find where Claude banner starts (has color codes before "Claude") + const claudeMatch = cleanBuffer.match(CLAUDE_BANNER_PATTERN); + if (claudeMatch && claudeMatch.index !== undefined && claudeMatch.index > 0) { + let lineStart = claudeMatch.index; + while (lineStart > 0 && cleanBuffer[lineStart - 1] !== '\n') { + lineStart--; + } + cleanBuffer = cleanBuffer.slice(lineStart); + } + } + + // Remove Ctrl+L and leading whitespace (cheap on tailed subset) + cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '').replace(LEADING_WHITESPACE_PATTERN, ''); + + return { + terminalBuffer: cleanBuffer, + status: session.status, + fullSize, + truncated, + }; + }); + + // ========== Auto-Clear ========== + + app.post('/api/sessions/:id/auto-clear', async (req) => { + const { id } = req.params as { id: string }; + const acResult = AutoClearSchema.safeParse(req.body); + if (!acResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const body = acResult.data; + const session = ctx.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + session.setAutoClear(body.enabled, body.threshold); + ctx.persistSessionState(session); + ctx.broadcast('session:updated', ctx.getSessionStateWithRespawn(session)); + + return { + success: true, + data: { + autoClear: { + enabled: session.autoClearEnabled, + threshold: session.autoClearThreshold, + }, + }, + }; + }); + + // ========== Auto-Compact ========== + + app.post('/api/sessions/:id/auto-compact', async (req) => { + const { id } = req.params as { id: string }; + const compactResult = AutoCompactSchema.safeParse(req.body); + if (!compactResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const body = compactResult.data; + const session = ctx.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + session.setAutoCompact(body.enabled, body.threshold, body.prompt); + ctx.persistSessionState(session); + ctx.broadcast('session:updated', ctx.getSessionStateWithRespawn(session)); + + return { + success: true, + data: { + autoCompact: { + enabled: session.autoCompactEnabled, + threshold: session.autoCompactThreshold, + prompt: session.autoCompactPrompt, + }, + }, + }; + }); + + // ========== Image Watcher ========== + + app.post('/api/sessions/:id/image-watcher', async (req) => { + const { id } = req.params as { id: string }; + const iwResult = ImageWatcherSchema.safeParse(req.body); + if (!iwResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const body = iwResult.data; + const session = ctx.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + if (body.enabled) { + imageWatcher.watchSession(session.id, session.workingDir); + } else { + imageWatcher.unwatchSession(session.id); + } + + // Store state on session for persistence + session.imageWatcherEnabled = body.enabled; + ctx.persistSessionState(session); + + return { + success: true, + data: { + imageWatcherEnabled: body.enabled, + }, + }; + }); + + // ========== Flicker Filter ========== + + app.post('/api/sessions/:id/flicker-filter', async (req) => { + const { id } = req.params as { id: string }; + const ffResult = FlickerFilterSchema.safeParse(req.body); + if (!ffResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const body = ffResult.data; + const session = ctx.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + + session.flickerFilterEnabled = body.enabled; + ctx.persistSessionState(session); + ctx.broadcast('session:updated', ctx.getSessionStateWithRespawn(session)); + + return { + success: true, + data: { + flickerFilterEnabled: body.enabled, + }, + }; + }); + + // ========== Quick Run ========== + + app.post('/api/run', async (req) => { + // Prevent unbounded session creation + if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) { + return createErrorResponse( + ApiErrorCode.SESSION_BUSY, + `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached` + ); + } + + const qrResult = QuickRunSchema.safeParse(req.body); + if (!qrResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const { prompt, workingDir } = qrResult.data; + + if (!prompt.trim()) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required'); + } + const dir = workingDir || process.cwd(); + + // Validate workingDir exists and is a directory + if (workingDir) { + try { + const stat = statSync(dir); + if (!stat.isDirectory()) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory'); + } + } catch { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist'); + } + } + + const session = new Session({ workingDir: dir }); + (ctx.sessions as Map).set(session.id, session); + ctx.store.incrementSessionsCreated(); + ctx.persistSessionState(session); + await ctx.setupSessionListeners(session); + getLifecycleLog().log({ + event: 'created', + sessionId: session.id, + name: session.name, + reason: 'run_prompt', + }); + + ctx.broadcast('session:created', ctx.getSessionStateWithRespawn(session)); + + try { + const result = await session.runPrompt(prompt); + // Clean up session after completion to prevent memory leak + await ctx.cleanupSession(session.id, true, 'run_prompt_complete'); + return { success: true, sessionId: session.id, ...result }; + } catch (err) { + // Clean up session on error too + await ctx.cleanupSession(session.id, true, 'run_prompt_error'); + return { success: false, sessionId: session.id, error: getErrorMessage(err) }; + } + }); + + // ========== Quick Start ========== + + app.post('/api/quick-start', async (req): Promise => { + // Prevent unbounded session creation + if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) { + return createErrorResponse( + ApiErrorCode.SESSION_BUSY, + `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.` + ); + } + + const result = QuickStartSchema.safeParse(req.body); + if (!result.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); + } + const { caseName = 'testcase', mode = 'claude', openCodeConfig } = result.data; + + // Check OpenCode availability if requested + if (mode === 'opencode') { + const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js'); + if (!isOpenCodeAvailable()) { + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash' + ); + } + } + + const casePath = join(casesDir, caseName); + + // Security: Path traversal protection - use relative path check + const resolvedPath = resolve(casePath); + const resolvedBase = resolve(casesDir); + const relPath = relative(resolvedBase, resolvedPath); + if (relPath.startsWith('..') || isAbsolute(relPath)) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path'); + } + + // Create case folder and CLAUDE.md if it doesn't exist + if (!existsSync(casePath)) { + try { + mkdirSync(casePath, { recursive: true }); + mkdirSync(join(casePath, 'src'), { recursive: true }); + + // Read settings to get custom template path + const templatePath = await ctx.getDefaultClaudeMdPath(); + const claudeMd = generateClaudeMd(caseName, '', templatePath); + writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd); + + // Write .claude/settings.local.json with hooks for desktop notifications + // (Claude-specific — OpenCode uses its own plugin system) + if (mode !== 'opencode') { + await writeHooksConfig(casePath); + } + + ctx.broadcast('case:created', { name: caseName, path: casePath }); + } catch (err) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`); + } + } + + // Create a new session with the case as working directory + // Apply global Nice priority config and model config from settings + const niceConfig = await ctx.getGlobalNiceConfig(); + const qsModelConfig = await ctx.getModelConfig(); + const qsModel = + mode === 'opencode' ? openCodeConfig?.model : mode !== 'shell' ? qsModelConfig?.defaultModel : undefined; + const qsClaudeModeConfig = await ctx.getClaudeModeConfig(); + const session = new Session({ + workingDir: casePath, + mux: ctx.mux, + useMux: true, + mode: mode, + niceConfig: niceConfig, + model: qsModel, + claudeMode: qsClaudeModeConfig.claudeMode, + allowedTools: qsClaudeModeConfig.allowedTools, + openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined, + }); + + // Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting + // so the initial state already has the phrase configured (only if globally enabled) + if (mode === 'claude' && ctx.store.getConfig().ralphEnabled) { + autoConfigureRalph(session, casePath, ctx); + if (!session.ralphTracker.enabled) { + session.ralphTracker.enable(); + session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart + } + } + + (ctx.sessions as Map).set(session.id, session); + ctx.store.incrementSessionsCreated(); + ctx.persistSessionState(session); + await ctx.setupSessionListeners(session); + getLifecycleLog().log({ + event: 'created', + sessionId: session.id, + name: session.name, + reason: 'quick_start', + }); + ctx.broadcast('session:created', ctx.getSessionStateWithRespawn(session)); + + // Start in the appropriate mode + try { + if (mode === 'shell') { + await session.startShell(); + getLifecycleLog().log({ + event: 'started', + sessionId: session.id, + name: session.name, + mode: 'shell', + }); + ctx.broadcast('session:interactive', { id: session.id, mode: 'shell' }); + } else { + // Both 'claude' and 'opencode' modes use startInteractive() + await session.startInteractive(); + getLifecycleLog().log({ + event: 'started', + sessionId: session.id, + name: session.name, + mode, + }); + ctx.broadcast('session:interactive', { id: session.id, mode }); + } + ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) }); + + // Save lastUsedCase to settings for TUI/web sync + try { + const settingsFilePath = settingsPath; + let settings: Record = {}; + try { + settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8')); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + settings.lastUsedCase = caseName; + const dir = dirname(settingsFilePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + // Use async write to avoid blocking event loop + fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => { + // Non-critical but log for debugging + console.warn('[Server] Failed to save settings (lastUsedCase):', err); + }); + } catch (err) { + // Non-critical but log for debugging + console.warn('[Server] Failed to prepare settings update:', err); + } + + return { + success: true, + sessionId: session.id, + casePath, + caseName, + }; + } catch (err) { + // Clean up session on error to prevent orphaned resources + await ctx.cleanupSession(session.id, true, 'quick_start_error'); + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + } + }); +} diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts new file mode 100644 index 00000000..2a48824b --- /dev/null +++ b/src/web/routes/system-routes.ts @@ -0,0 +1,673 @@ +/** + * @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 { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js'; +import { + ConfigUpdateSchema, + SettingsUpdateSchema, + ModelConfigUpdateSchema, + CpuLimitSchema, + SubagentWindowStatesSchema, + SubagentParentMapSchema, +} 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 } from '../route-helpers.js'; +import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.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 +): void { + const settingsPath = join(homedir(), '.codeman', 'settings.json'); + const windowStatesPath = join(homedir(), '.codeman', 'subagent-window-states.json'); + const parentMapPath = join(homedir(), '.codeman', 'subagent-parents.json'); + + // ========== 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 QRCode = require('qrcode'); + const svg: string = await QRCode.toString(url, { type: 'svg', margin: 2, width: 256 }); + return { svg }; + } catch (err) { + return reply.code(500).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err))); + } + }); + + // ========== OpenCode ========== + + app.get('/api/opencode/status', async () => { + const { isOpenCodeAvailable, resolveOpenCodeDir } = await import('../../utils/opencode-cli-resolver.js'); + return { + available: isOpenCodeAvailable(), + path: resolveOpenCodeDir(), + }; + }); + + // ========== 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 = {}; + 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 = {}; + 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), + }; + }); + + // ========== 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>); + 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(settingsPath, '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; + + try { + const dir = dirname(settingsPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + let existing: Record = {}; + try { + existing = JSON.parse(await fs.readFile(settingsPath, 'utf-8')); + } catch { + /* ignore */ + } + const merged = { ...existing, ...settings }; + await fs.writeFile(settingsPath, 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('tunnel:started', { 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(settingsPath, '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; + + try { + let existingSettings: Record = {}; + try { + const content = await fs.readFile(settingsPath, 'utf-8'); + existingSettings = JSON.parse(content); + } catch { + // File doesn't exist yet, start fresh + } + + existingSettings.modelConfig = modelConfig; + + const dir = dirname(settingsPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + await fs.writeFile(settingsPath, 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; + + session.setNice(body); + ctx.persistSessionState(session); + ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) }); + + return { + success: true, + nice: session.niceConfig, + note: 'Nice priority only affects newly created mux sessions, not currently running ones.', + }; + }); + + // ========== 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; + 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 ========== + + 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 = { + 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 = { + 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); + }); +} diff --git a/src/web/routes/team-routes.ts b/src/web/routes/team-routes.ts new file mode 100644 index 00000000..3409fb13 --- /dev/null +++ b/src/web/routes/team-routes.ts @@ -0,0 +1,18 @@ +/** + * @fileoverview Team routes. + * Provides read-only access to agent team data from TeamWatcher. + */ + +import { FastifyInstance } from 'fastify'; +import type { InfraPort } from '../ports/index.js'; + +export function registerTeamRoutes(app: FastifyInstance, ctx: InfraPort): void { + app.get('/api/teams', async () => { + return { success: true, data: ctx.teamWatcher.getTeams() }; + }); + + app.get('/api/teams/:name/tasks', async (req) => { + const { name } = req.params as { name: string }; + return { success: true, data: ctx.teamWatcher.getTeamTasks(name) }; + }); +} diff --git a/src/web/server.ts b/src/web/server.ts index a96c3542..91465213 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -14,23 +14,12 @@ 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 { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { - existsSync, - statSync, - mkdirSync, - writeFileSync, - readdirSync, - readFileSync, - rmSync, - chmodSync, - realpathSync, -} from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, chmodSync } from 'node:fs'; import fs from 'node:fs/promises'; import { execSync } from 'node:child_process'; -import { randomBytes, timingSafeEqual } from 'node:crypto'; -import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os'; +import { homedir } from 'node:os'; import { EventEmitter } from 'node:events'; import { Session, @@ -41,14 +30,12 @@ import { type ActiveBashTool, } from '../session.js'; import type { ClaudeMode } from '../types.js'; -import { fileStreamManager } from '../file-stream-manager.js'; import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js'; import type { TerminalMultiplexer } from '../mux-interface.js'; import { createMultiplexer } from '../mux-factory.js'; import { getStore } from '../state-store.js'; -import { generateClaudeMd } from '../templates/claude-md.js'; -import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js'; -import { writeHooksConfig, updateCaseEnvVars } from '../hooks-config.js'; +import { extractCompletionPhrase } from '../ralph-config.js'; +import { fileStreamManager } from '../file-stream-manager.js'; import { subagentWatcher, type SubagentInfo, @@ -64,7 +51,7 @@ import { TunnelManager } from '../tunnel-manager.js'; import { v4 as uuidv4 } from 'uuid'; import { createRequire } from 'node:module'; import { RunSummaryTracker } from '../run-summary.js'; -import { PlanOrchestrator, type DetailedPlanResult } from '../plan-orchestrator.js'; +import { PlanOrchestrator } from '../plan-orchestrator.js'; import { getLifecycleLog } from '../session-lifecycle-log.js'; import { PushSubscriptionStore } from '../push-store.js'; import webpush from 'web-push'; @@ -76,72 +63,32 @@ import { getErrorMessage, ApiErrorCode, createErrorResponse, - type ApiResponse, - type QuickStartResponse, - type CaseInfo, type PersistedRespawnConfig, type NiceConfig, type ImageDetectedEvent, DEFAULT_NICE_CONFIG, } from '../types.js'; -import { - CreateSessionSchema, - RunPromptSchema, - SessionInputWithLimitSchema, - ResizeSchema, - CreateCaseSchema, - QuickStartSchema, - HookEventSchema, - ConfigUpdateSchema, - RespawnConfigSchema, - SessionNameSchema, - SessionColorSchema, - RalphConfigSchema, - FixPlanImportSchema, - RalphPromptWriteSchema, - AutoClearSchema, - AutoCompactSchema, - ImageWatcherSchema, - FlickerFilterSchema, - QuickRunSchema, - ScheduledRunSchema, - LinkCaseSchema, - GeneratePlanSchema, - GeneratePlanDetailedSchema, - CancelPlanSchema, - PlanTaskUpdateSchema, - PlanTaskAddSchema, - CpuLimitSchema, - SettingsUpdateSchema, - ModelConfigUpdateSchema, - SubagentWindowStatesSchema, - SubagentParentMapSchema, - InteractiveRespawnSchema, - RespawnEnableSchema, - PushSubscribeSchema, - PushPreferencesUpdateSchema, - RalphLoopStartSchema, - isValidWorkingDir, -} from './schemas.js'; import { CleanupManager, KeyedDebouncer, StaleExpirationMap } from '../utils/index.js'; import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js'; +import type { ScheduledRun } from './ports/index.js'; +import { registerAuthMiddleware, registerSecurityHeaders } from './middleware/auth.js'; +import { + registerPushRoutes, + registerTeamRoutes, + registerMuxRoutes, + registerFileRoutes, + registerScheduledRoutes, + registerHookEventRoutes, + registerSystemRoutes, + registerCaseRoutes, + registerSessionRoutes, + registerRespawnRoutes, + registerRalphRoutes, + registerPlanRoutes, +} from './routes/index.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); -interface ScheduledRun { - id: string; - prompt: string; - workingDir: string; - durationMinutes: number; - startedAt: number; - endAt: number; - status: 'running' | 'completed' | 'failed' | 'stopped'; - sessionId: string | null; - completedTasks: number; - totalCost: number; - logs: string[]; -} - // Batch terminal data for performance - collect for 16ms (60fps) before sending const TERMINAL_BATCH_INTERVAL = 16; // Batch task:updated events for 100ms @@ -163,31 +110,6 @@ const SCHEDULED_CLEANUP_INTERVAL = 5 * 60 * 1000; const SCHEDULED_RUN_MAX_AGE = 60 * 60 * 1000; // SSE client health check interval (every 30 seconds) const SSE_HEALTH_CHECK_INTERVAL = 30 * 1000; -// Maximum allowed input length for session write (64KB) -const MAX_INPUT_LENGTH = 64 * 1024; -// Maximum terminal resize dimensions -const MAX_TERMINAL_COLS = 500; -const MAX_TERMINAL_ROWS = 200; -// Maximum session name length -const MAX_SESSION_NAME_LENGTH = 128; -// Maximum hook data size (prevents oversized SSE broadcasts) -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) -const STATS_COLLECTION_INTERVAL_MS = 2000; // Session limit wait time before retrying (5 seconds) const SESSION_LIMIT_WAIT_MS = 5000; // Pause between scheduled run iterations (2 seconds) @@ -195,134 +117,8 @@ const ITERATION_PAUSE_MS = 2000; // Terminal batch flush threshold - flush immediately if batch exceeds this size // Set high (32KB) to allow effective batching; avg Ink events are ~14KB const BATCH_FLUSH_THRESHOLD = 32 * 1024; -// Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation) -// eslint-disable-next-line no-control-regex -const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/; -// eslint-disable-next-line no-control-regex -const CTRL_L_PATTERN = /\x0c/g; -const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/; - -/** - * Formats uptime in seconds to a human-readable string. - */ -function formatUptime(seconds: number): string { - const days = Math.floor(seconds / 86400); - const hours = Math.floor((seconds % 86400) / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = Math.floor(seconds % 60); - - const parts: string[] = []; - if (days > 0) parts.push(`${days}d`); - if (hours > 0) parts.push(`${hours}h`); - if (minutes > 0) parts.push(`${minutes}m`); - if (secs > 0 || parts.length === 0) parts.push(`${secs}s`); - - return parts.join(' '); -} - -/** - * Sanitizes hook event data before broadcasting via SSE. - * Extracts only relevant fields and limits total size to prevent - * oversized payloads from being broadcast to all connected clients. - */ -function sanitizeHookData(data: Record | null | undefined): Record { - if (!data || typeof data !== 'object') return {}; - - // Only forward known safe fields from Claude Code hook stdin - const safeFields: Record = {}; - const allowedKeys = [ - 'hook_event_name', - 'tool_name', - 'tool_input', - 'session_id', - 'cwd', - 'permission_mode', - 'stop_hook_active', - 'transcript_path', - ]; - - for (const key of allowedKeys) { - if (key in data && data[key] !== undefined) { - safeFields[key] = data[key]; - } - } - - // For tool_input, extract only summary fields (not full file content) - if (safeFields.tool_input && typeof safeFields.tool_input === 'object') { - const input = safeFields.tool_input as Record; - const summary: Record = {}; - if (input.command) summary.command = String(input.command).slice(0, 500); - if (input.file_path) summary.file_path = String(input.file_path).slice(0, 500); - if (input.description) summary.description = String(input.description).slice(0, 200); - if (input.query) summary.query = String(input.query).slice(0, 200); - if (input.url) summary.url = String(input.url).slice(0, 500); - if (input.pattern) summary.pattern = String(input.pattern).slice(0, 200); - if (input.prompt) summary.prompt = String(input.prompt).slice(0, 200); - safeFields.tool_input = summary; - } - - // Final size check - drop if serialized data exceeds limit - const serialized = JSON.stringify(safeFields); - if (serialized.length > MAX_HOOK_DATA_SIZE) { - return { tool_name: safeFields.tool_name, _truncated: true }; - } - - return safeFields; -} - -/** - * Auto-configure Ralph tracker for a session. - * - * Priority order: - * 1. .claude/ralph-loop.local.md (official Ralph Wiggum plugin state) - * 2. CLAUDE.md tags (fallback) - * - * The ralph-loop.local.md file has priority because it contains - * the exact configuration from an active Ralph loop session. - */ -function autoConfigureRalph( - session: Session, - workingDir: string, - broadcast: (event: string, data: unknown) => void -): void { - // First, try to read the official Ralph Wiggum plugin state file - const ralphConfig = parseRalphLoopConfig(workingDir); - - if (ralphConfig && ralphConfig.completionPromise) { - session.ralphTracker.enable(); - session.ralphTracker.startLoop(ralphConfig.completionPromise, ralphConfig.maxIterations ?? undefined); - - // Restore iteration count if available - if (ralphConfig.iteration > 0) { - // The tracker's cycleCount will be updated when we detect iteration patterns - // in the terminal output, but we can set maxIterations now - console.log(`[auto-detect] Ralph loop at iteration ${ralphConfig.iteration}/${ralphConfig.maxIterations ?? '∞'}`); - } - - console.log( - `[auto-detect] Configured Ralph loop for session ${session.id} from ralph-loop.local.md: ${ralphConfig.completionPromise}` - ); - broadcast('session:ralphLoopUpdate', { - sessionId: session.id, - state: session.ralphTracker.loopState, - }); - return; - } - - // Fallback: try CLAUDE.md - const claudeMdPath = join(workingDir, 'CLAUDE.md'); - const completionPhrase = extractCompletionPhrase(claudeMdPath); - - if (completionPhrase) { - session.ralphTracker.enable(); - session.ralphTracker.startLoop(completionPhrase); - console.log(`[auto-detect] Configured Ralph loop for session ${session.id} from CLAUDE.md: ${completionPhrase}`); - broadcast('session:ralphLoopUpdate', { - sessionId: session.id, - state: session.ralphTracker.loopState, - }); - } -} +// Stats collection interval (2 seconds) +const STATS_COLLECTION_INTERVAL_MS = 2000; /** * Get or generate a self-signed TLS certificate for HTTPS. @@ -390,9 +186,6 @@ interface SessionListenerRefs { } export class WebServer extends EventEmitter { - /** Cached CPU count — doesn't change at runtime */ - private static readonly CPU_COUNT = cpus().length; - private app: FastifyInstance; private sessions: Map = new Map(); private respawnControllers: Map = new Map(); @@ -639,6 +432,58 @@ export class WebServer extends EventEmitter { } } + /** + * Build a route context object satisfying all 5 port interfaces. + * Single object with zero runtime cost — ISP enforced at the type level. + */ + private createRouteContext() { + return { + // SessionPort + sessions: this.sessions as ReadonlyMap, + cleanupSession: this.cleanupSession.bind(this), + setupSessionListeners: this.setupSessionListeners.bind(this), + persistSessionState: this.persistSessionState.bind(this), + persistSessionStateNow: this._persistSessionStateNow.bind(this), + getSessionStateWithRespawn: this.getSessionStateWithRespawn.bind(this), + // EventPort + broadcast: this.broadcast.bind(this), + sendPushNotifications: this.sendPushNotifications.bind(this), + batchTerminalData: this.batchTerminalData.bind(this), + broadcastSessionStateDebounced: this.broadcastSessionStateDebounced.bind(this), + batchTaskUpdate: this.batchTaskUpdate.bind(this), + // RespawnPort + respawnControllers: this.respawnControllers, + respawnTimers: this.respawnTimers, + setupRespawnListeners: this.setupRespawnListeners.bind(this), + setupTimedRespawn: this.setupTimedRespawn.bind(this), + restoreRespawnController: this.restoreRespawnController.bind(this), + saveRespawnConfig: this.saveRespawnConfig.bind(this), + // ConfigPort + store: this.store, + port: this.port, + https: this.https, + testMode: this.testMode, + serverStartTime: this.serverStartTime, + getGlobalNiceConfig: this.getGlobalNiceConfig.bind(this), + getModelConfig: this.getModelConfig.bind(this), + getClaudeModeConfig: this.getClaudeModeConfig.bind(this), + getDefaultClaudeMdPath: this.getDefaultClaudeMdPath.bind(this), + getLightState: this.getLightState.bind(this), + startTranscriptWatcher: this.startTranscriptWatcher.bind(this), + stopTranscriptWatcher: this.stopTranscriptWatcher.bind(this), + // InfraPort + mux: this.mux, + runSummaryTrackers: this.runSummaryTrackers, + activePlanOrchestrators: this.activePlanOrchestrators, + scheduledRuns: this.scheduledRuns, + teamWatcher: this.teamWatcher, + tunnelManager: this.tunnelManager, + pushStore: this.pushStore, + startScheduledRun: this.startScheduledRun.bind(this), + stopScheduledRun: this.stopScheduledRun.bind(this), + }; + } + private async setupRoutes(): Promise { // Allow multipart/form-data for screenshot uploads — skip Fastify's body parser // so the route handler can read the raw stream directly. @@ -656,130 +501,15 @@ export class WebServer extends EventEmitter { // 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({ - ttlMs: AUTH_SESSION_TTL_MS, - refreshOnGet: true, - }); - - // Failure counter per IP — decay naturally after 15 minutes - this.authFailures = new StaleExpirationMap({ - ttlMs: AUTH_FAILURE_WINDOW_MS, - refreshOnGet: false, - }); - - this.app.addHook('onRequest', (req, reply, done) => { - // Hook events come from local Claude Code hooks (curl from localhost) — no auth headers available. - // Safe: validated by HookEventSchema, only triggers broadcasts. - // Security: restrict bypass to localhost only — prevents forged hook events via tunnel/LAN. - if (req.url === '/api/hook-event' && req.method === 'POST') { - const ip = req.ip; - if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') { - done(); - return; - } - // Non-localhost hook requests fall through to normal auth - } - - 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) - // Use get() instead of has() so refreshOnGet extends the TTL on active sessions - const sessionToken = req.cookies[AUTH_COOKIE_NAME]; - if (sessionToken && this.authSessions!.get(sessionToken) !== undefined) { - done(); - return; - } - - // Check Basic Auth header (timing-safe comparison to prevent side-channel attacks) - const auth = req.headers.authorization; - const authBuf = Buffer.from(auth ?? ''); - const expectedBuf = Buffer.from(expectedHeader); - if (authBuf.length === expectedBuf.length && timingSafeEqual(authBuf, expectedBuf)) { - // 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'); - }); + // Auth middleware (Basic Auth + session cookies + rate limiting) + const authState = registerAuthMiddleware(this.app, this.https); + if (authState) { + this.authSessions = authState.authSessions; + this.authFailures = authState.authFailures; } - // Security headers + CORS on every response - this.app.addHook('onRequest', (req, reply, done) => { - reply.header('X-Content-Type-Options', 'nosniff'); - reply.header('X-Frame-Options', 'SAMEORIGIN'); - reply.header( - 'Content-Security-Policy', - "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: blob:; connect-src 'self' wss://api.deepgram.com; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'" - ); - if (this.https) { - reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); - } - - // CORS: restrict to same-origin (localhost) only - const origin = req.headers.origin; - if (origin) { - try { - const url = new URL(origin); - if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1') { - reply.header('Access-Control-Allow-Origin', origin); - reply.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); - reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); - reply.header('Access-Control-Max-Age', '86400'); - } - } catch { - // Invalid origin header — do not set CORS headers - } - } - - // Handle CORS preflight - if (req.method === 'OPTIONS') { - reply.code(204).send(); - done(); - return; - } - - done(); - }); - + // Security headers + CORS + registerSecurityHeaders(this.app, this.https); // Service worker must never be cached — browsers check for SW updates on navigation this.app.get('/sw.js', async (_req, reply) => { return reply @@ -827,3755 +557,31 @@ export class WebServer extends EventEmitter { }); }); - // API Routes - - // Logout: invalidate session cookie - this.app.post('/api/logout', async (req, reply) => { - const sessionToken = req.cookies[AUTH_COOKIE_NAME]; - if (sessionToken && this.authSessions) { - this.authSessions.delete(sessionToken); - } - reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' }); - return { success: true }; - }); - - 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'); - return { - available: isOpenCodeAvailable(), - path: resolveOpenCodeDir(), - }; - }); - - // Cleanup stale sessions from state file - this.app.post('/api/cleanup-state', async () => { - const cleaned = this.cleanupStaleSessions(); - return { success: true, cleanedSessions: cleaned }; - }); - - // Session lifecycle audit log - this.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 }; - }); - - // Global stats endpoint - this.app.get('/api/stats', async () => { - const activeSessionTokens: Record = - {}; - for (const [sessionId, session] of this.sessions) { - activeSessionTokens[sessionId] = { - inputTokens: session.inputTokens, - outputTokens: session.outputTokens, - totalCost: session.totalCost, - }; - } - return { - success: true, - stats: this.store.getAggregateStats(activeSessionTokens), - raw: this.store.getGlobalStats(), - }; - }); - - // Token stats with daily history - this.app.get('/api/token-stats', async () => { - // Get aggregate totals (global + active sessions) - const activeSessionTokens: Record = - {}; - for (const [sessionId, session] of this.sessions) { - activeSessionTokens[sessionId] = { - inputTokens: session.inputTokens, - outputTokens: session.outputTokens, - totalCost: session.totalCost, - }; - } - return { - success: true, - daily: this.store.getDailyStats(30), - totals: this.store.getAggregateStats(activeSessionTokens), - }; - }); - - this.app.get('/api/config', async () => { - return { success: true, config: this.store.getConfig() }; - }); - - this.app.put('/api/config', async (req) => { - // Validate request body against schema to prevent arbitrary config injection - const parseResult = ConfigUpdateSchema.safeParse(req.body); - if (!parseResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid config: ${parseResult.error.message}`); - } - this.store.setConfig(parseResult.data as Partial>); - return { success: true, config: this.store.getConfig() }; - }); - - // Debug/monitoring endpoint - lightweight, only runs when called - // Returns comprehensive memory metrics for debugging memory leaks - this.app.get('/api/debug/memory', async () => { - const mem = process.memoryUsage(); - const subagentStats = subagentWatcher.getStats(); - - // Calculate total Map entries for memory estimation - const serverMapSizes = { - sessions: this.sessions.size, - sseClients: this.sseClients.size, - respawnControllers: this.respawnControllers.size, - runSummaryTrackers: this.runSummaryTrackers.size, - transcriptWatchers: this.transcriptWatchers.size, - scheduledRuns: this.scheduledRuns.size, - terminalBatches: this.terminalBatches.size, - taskUpdateBatches: this.taskUpdateBatches.size, - stateUpdatePending: this.stateUpdatePending.size, - lastRecordedTokens: this.lastRecordedTokens.size, - pendingRespawnStarts: this.pendingRespawnStarts.size, - respawnTimers: this.respawnTimers.size, - activePlanOrchestrators: this.activePlanOrchestrators.size, - cleaningUp: this.cleaningUp.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, - transcriptWatchers: this.transcriptWatchers.size, - total: subagentStats.fileDebouncerCount + subagentStats.dirWatcherCount + this.transcriptWatchers.size, - }, - timers: { - respawnTimers: this.respawnTimers.size, - pendingRespawnStarts: this.pendingRespawnStarts.size, - subagentIdleTimers: subagentStats.idleTimerCount, - total: this.respawnTimers.size + this.pendingRespawnStarts.size + subagentStats.idleTimerCount, - }, - uptime: { - seconds: Math.round(process.uptime()), - formatted: formatUptime(process.uptime()), - }, - timestamp: Date.now(), - }; - }); - - // Session management - this.app.get('/api/sessions', async () => this.getLightSessionsState()); - - this.app.post('/api/sessions', async (req) => { - // Prevent unbounded session creation - if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) { - return createErrorResponse( - ApiErrorCode.OPERATION_FAILED, - `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached. Delete some sessions first.` - ); - } - - const result = CreateSessionSchema.safeParse(req.body); - if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); - } - const body = result.data; - const workingDir = body.workingDir || process.cwd(); - - // Validate workingDir exists and is a directory - if (body.workingDir) { - try { - const stat = statSync(workingDir); - if (!stat.isDirectory()) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory'); - } - } catch { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist'); - } - } - - // Write env overrides to .claude/settings.local.json if provided - if (body.envOverrides && Object.keys(body.envOverrides).length > 0) { - await updateCaseEnvVars(workingDir, body.envOverrides); - } - - // Check OpenCode availability if requested - if (body.mode === 'opencode') { - const { isOpenCodeAvailable } = await import('../utils/opencode-cli-resolver.js'); - if (!isOpenCodeAvailable()) { - return createErrorResponse( - ApiErrorCode.OPERATION_FAILED, - 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash' - ); - } - } - - const globalNice = await this.getGlobalNiceConfig(); - const modelConfig = await this.getModelConfig(); - const mode = body.mode || 'claude'; - const model = - mode === 'opencode' ? body.openCodeConfig?.model : mode !== 'shell' ? modelConfig?.defaultModel : undefined; - const claudeModeConfig = await this.getClaudeModeConfig(); - const session = new Session({ - workingDir, - mode, - name: body.name || '', - mux: this.mux, - useMux: true, - niceConfig: globalNice, - model, - claudeMode: claudeModeConfig.claudeMode, - allowedTools: claudeModeConfig.allowedTools, - openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined, - }); - - this.sessions.set(session.id, session); - this.store.incrementSessionsCreated(); - this.persistSessionState(session); - await this.setupSessionListeners(session); - getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name }); - - // Use light state for broadcast + response — buffers are fetched on-demand via /terminal. - // Avoids serializing 2-3MB of terminal+text buffers per session creation. - const lightState = this.getSessionStateWithRespawn(session); - this.broadcast('session:created', lightState); - return { success: true, session: lightState }; - }); - - // Rename a session - this.app.put('/api/sessions/:id/name', async (req) => { - const { id } = req.params as { id: string }; - const result = SessionNameSchema.safeParse(req.body); - if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const body = result.data; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH); - session.name = name; - // Also update the mux session name if applicable - this.mux.updateSessionName(id, session.name); - this.persistSessionState(session); - this.broadcast('session:updated', this.getSessionStateWithRespawn(session)); - return { success: true, name: session.name }; - }); - - // Set session color - this.app.put('/api/sessions/:id/color', async (req) => { - const { id } = req.params as { id: string }; - const result = SessionColorSchema.safeParse(req.body); - if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const body = result.data; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - const validColors = ['default', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink']; - if (!validColors.includes(body.color)) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid color'); - } - - session.setColor(body.color as import('../types.js').SessionColor); - this.persistSessionState(session); - this.broadcast('session:updated', this.getSessionStateWithRespawn(session)); - return { success: true, color: session.color }; - }); - - this.app.delete('/api/sessions/:id', async (req): Promise => { - const { id } = req.params as { id: string }; - const query = req.query as { killMux?: string }; - const killMux = query.killMux !== 'false'; // Default to true - - if (!this.sessions.has(id)) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - await this.cleanupSession(id, killMux, 'user_delete'); - return { success: true }; - }); - - // Kill all sessions at once - this.app.delete('/api/sessions', async (): Promise> => { - const sessionIds = Array.from(this.sessions.keys()); - let killed = 0; - - for (const id of sessionIds) { - if (this.sessions.has(id)) { - await this.cleanupSession(id, true, 'user_bulk_delete'); - killed++; - } - } - - return { success: true, data: { killed } }; - }); - - this.app.get('/api/sessions/:id', async (req) => { - const { id } = req.params as { id: string }; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - // Use light state (no full buffers) — terminal buffer available via /terminal endpoint. - // Full buffers were 2-3MB and caused slowness when polled frequently (e.g. Ralph wizard). - return this.getSessionStateWithRespawn(session); - }); - - this.app.get('/api/sessions/:id/output', async (req) => { - const { id } = req.params as { id: string }; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - return { - success: true, - data: { - textOutput: session.textOutput, - messages: session.messages, - errorBuffer: session.errorBuffer, - }, - }; - }); - - // Get Ralph state (Ralph loop + todos) for a session - this.app.get('/api/sessions/:id/ralph-state', async (req) => { - const { id } = req.params as { id: string }; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - return { - success: true, - data: { - loop: session.ralphLoopState, - todos: session.ralphTodos, - todoStats: session.ralphTodoStats, - }, - }; - }); - - // Get run summary for a session (what happened while you were away) - this.app.get('/api/sessions/:id/run-summary', async (req) => { - const { id } = req.params as { id: string }; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - const tracker = this.runSummaryTrackers.get(id); - if (!tracker) { - // Create a fresh tracker if one doesn't exist (shouldn't happen normally) - const newTracker = new RunSummaryTracker(id, session.name); - this.runSummaryTrackers.set(id, newTracker); - return { success: true, summary: newTracker.getSummary() }; - } - - // Update session name in case it changed - tracker.setSessionName(session.name); - - return { success: true, summary: tracker.getSummary() }; - }); - - // Get active Bash tools for a session (file-viewing commands) - this.app.get('/api/sessions/:id/active-tools', async (req) => { - const { id } = req.params as { id: string }; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - return { - success: true, - data: { - tools: session.activeTools, - }, - }; - }); - - // Get file tree for session's working directory (File Browser) - this.app.get('/api/sessions/:id/files', async (req) => { - const { id } = req.params as { id: string }; - const { depth, showHidden } = req.query as { depth?: string; showHidden?: string }; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - const maxDepth = Math.min(parseInt(depth || '5', 10), 10); - const includeHidden = showHidden === 'true'; - const workingDir = session.workingDir; - - // Default excludes - large/generated directories - const excludeDirs = new Set([ - '.git', - 'node_modules', - 'dist', - 'build', - '__pycache__', - '.cache', - '.next', - '.nuxt', - 'coverage', - '.venv', - 'venv', - '.tox', - 'target', - 'vendor', - ]); - - interface FileTreeNode { - name: string; - path: string; - type: 'file' | 'directory'; - size?: number; - extension?: string; - children?: FileTreeNode[]; - } - - let totalFiles = 0; - let totalDirectories = 0; - let truncated = false; - const maxFiles = 5000; - - const scanDirectory = async (dirPath: string, currentDepth: number): Promise => { - if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles) { - truncated = true; - return []; - } - - try { - const entries = await fs.readdir(dirPath, { withFileTypes: true }); - const nodes: FileTreeNode[] = []; - - // Sort: directories first, then alphabetically - entries.sort((a, b) => { - if (a.isDirectory() && !b.isDirectory()) return -1; - if (!a.isDirectory() && b.isDirectory()) return 1; - return a.name.localeCompare(b.name); - }); - - for (const entry of entries) { - if (totalFiles + totalDirectories > maxFiles) { - truncated = true; - break; - } - - // Skip hidden files unless requested - if (!includeHidden && entry.name.startsWith('.')) continue; - - // Skip excluded directories - if (entry.isDirectory() && excludeDirs.has(entry.name)) continue; - - const fullPath = join(dirPath, entry.name); - const relativePath = fullPath.slice(workingDir.length + 1); - - if (entry.isDirectory()) { - totalDirectories++; - const children = await scanDirectory(fullPath, currentDepth + 1); - nodes.push({ - name: entry.name, - path: relativePath, - type: 'directory', - children, - }); - } else { - totalFiles++; - const ext = entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined; - let size: number | undefined; - try { - const stat = await fs.stat(fullPath); - size = stat.size; - } catch { - // Skip if can't stat - } - nodes.push({ - name: entry.name, - path: relativePath, - type: 'file', - size, - extension: ext, - }); - } - } - - return nodes; - } catch (err) { - // Can't read directory (permission denied, etc.) - return []; - } - }; - - const tree = await scanDirectory(workingDir, 1); - - return { - success: true, - data: { - root: workingDir, - tree, - totalFiles, - totalDirectories, - truncated, - }, - }; - }); - - // Get file content for preview (File Browser) - this.app.get('/api/sessions/:id/file-content', async (req) => { - const { id } = req.params as { id: string }; - const { path: filePath, lines, raw } = req.query as { path?: string; lines?: string; raw?: string }; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - if (!filePath) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'); - } - - // Validate path is within working directory (security: resolve symlinks to prevent traversal) - const fullPath = resolve(session.workingDir, filePath); - let resolvedPath: string; - try { - resolvedPath = realpathSync(fullPath); - } catch { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'); - } - const relativePath = relative(session.workingDir, resolvedPath); - if (relativePath.startsWith('..') || isAbsolute(relativePath)) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory'); - } - - try { - const stat = await fs.stat(resolvedPath); - - // Check if it's a binary/media file - const ext = filePath.split('.').pop()?.toLowerCase() || ''; - const binaryExts = new Set([ - 'png', - 'jpg', - 'jpeg', - 'gif', - 'webp', - 'ico', - 'svg', - 'bmp', - 'mp4', - 'webm', - 'mov', - 'avi', - 'mp3', - 'wav', - 'ogg', - 'pdf', - 'zip', - 'tar', - 'gz', - 'exe', - 'dll', - 'so', - 'woff', - 'woff2', - 'ttf', - 'eot', - ]); - const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']); - const videoExts = new Set(['mp4', 'webm', 'mov', 'avi']); - - if (raw === 'true' || binaryExts.has(ext)) { - // Return metadata for binary files - return { - success: true, - data: { - path: filePath, - size: stat.size, - type: imageExts.has(ext) ? 'image' : videoExts.has(ext) ? 'video' : 'binary', - extension: ext, - url: `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`, - }, - }; - } - - // Validate file size before reading (DoS protection - prevent memory exhaustion) - const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024; // 10MB - if (stat.size > MAX_TEXT_FILE_SIZE) { - return createErrorResponse( - ApiErrorCode.INVALID_INPUT, - `File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit)` - ); - } - - // Read text file with line limit (bounded to prevent DoS) - const MAX_LINES_LIMIT = 10000; - const maxLines = Math.min(parseInt(lines || '500', 10) || 500, MAX_LINES_LIMIT); - const content = await fs.readFile(resolvedPath, 'utf-8'); - const allLines = content.split('\n'); - const truncatedContent = allLines.length > maxLines; - const displayContent = truncatedContent ? allLines.slice(0, maxLines).join('\n') : content; - - return { - success: true, - data: { - path: filePath, - content: displayContent, - size: stat.size, - totalLines: allLines.length, - truncated: truncatedContent, - extension: ext, - }, - }; - } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`); - } - }); - - // Serve raw file content (for images/binary files) - this.app.get('/api/sessions/:id/file-raw', async (req, reply) => { - const { id } = req.params as { id: string }; - const { path: filePath } = req.query as { path?: string }; - const session = this.sessions.get(id); - - if (!session) { - reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found')); - return; - } - - if (!filePath) { - reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter')); - return; - } - - // Validate path is within working directory (security: resolve symlinks to prevent traversal) - const fullPath = resolve(session.workingDir, filePath); - let resolvedPath: string; - try { - resolvedPath = realpathSync(fullPath); - } catch { - reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found')); - return; - } - const relativePath = relative(session.workingDir, resolvedPath); - if (relativePath.startsWith('..') || isAbsolute(relativePath)) { - reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory')); - return; - } - - try { - // Validate file size before reading (DoS protection - prevent memory exhaustion) - const MAX_RAW_FILE_SIZE = 50 * 1024 * 1024; // 50MB for raw files - const stat = await fs.stat(resolvedPath); - if (stat.size > MAX_RAW_FILE_SIZE) { - reply - .code(400) - .send( - createErrorResponse( - ApiErrorCode.INVALID_INPUT, - `File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_RAW_FILE_SIZE / 1024 / 1024}MB limit)` - ) - ); - return; - } - - const ext = filePath.split('.').pop()?.toLowerCase() || ''; - const mimeTypes: Record = { - png: 'image/png', - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - gif: 'image/gif', - webp: 'image/webp', - svg: 'image/svg+xml', - ico: 'image/x-icon', - bmp: 'image/bmp', - mp4: 'video/mp4', - webm: 'video/webm', - mov: 'video/quicktime', - mp3: 'audio/mpeg', - wav: 'audio/wav', - ogg: 'audio/ogg', - pdf: 'application/pdf', - json: 'application/json', - }; - - const content = await fs.readFile(resolvedPath); - reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream'); - reply.send(content); - } catch (err) { - reply - .code(500) - .send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`)); - } - }); - - // Stream file content via tail -f (SSE endpoint) - this.app.get('/api/sessions/:id/tail-file', async (req, reply) => { - const { id } = req.params as { id: string }; - const { path: filePath, lines } = req.query as { path?: string; lines?: string }; - const session = this.sessions.get(id); - - if (!session) { - reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found')); - return; - } - - if (!filePath) { - reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter')); - return; - } - - // Set up SSE headers - reply.raw.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - }); - - // Track stream for cleanup - const streamRef: { id?: string } = {}; - - // Create the file stream - const result = await fileStreamManager.createStream({ - sessionId: id, - filePath, - workingDir: session.workingDir, - lines: lines ? parseInt(lines, 10) : undefined, - onData: (data) => { - // Send data as SSE event - reply.raw.write(`data: ${JSON.stringify({ type: 'data', content: data })}\n\n`); - }, - onEnd: () => { - reply.raw.write(`data: ${JSON.stringify({ type: 'end' })}\n\n`); - reply.raw.end(); - }, - onError: (error) => { - reply.raw.write(`data: ${JSON.stringify({ type: 'error', error })}\n\n`); - }, - }); - - if (!result.success) { - reply.raw.write(`data: ${JSON.stringify({ type: 'error', error: result.error })}\n\n`); - reply.raw.end(); - return; - } - - streamRef.id = result.streamId; - - // Notify client of successful connection - reply.raw.write(`data: ${JSON.stringify({ type: 'connected', streamId: result.streamId, filePath })}\n\n`); - - // Handle client disconnect - req.raw.on('close', () => { - if (streamRef.id) { - fileStreamManager.closeStream(streamRef.id); - } - }); - }); - - // Close a file stream - this.app.delete('/api/sessions/:id/tail-file/:streamId', async (req) => { - const { id, streamId } = req.params as { id: string; streamId: string }; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - const closed = fileStreamManager.closeStream(streamId); - return { success: closed }; - }); - - // Configure Ralph (Ralph Wiggum) settings - this.app.post('/api/sessions/:id/ralph-config', async (req) => { - const { id } = req.params as { id: string }; - const ralphResult = RalphConfigSchema.safeParse(req.body); - if (!ralphResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = ralphResult.data as { - enabled?: boolean; - completionPhrase?: string; - maxIterations?: number; - reset?: boolean | 'full'; - disableAutoEnable?: boolean; - }; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - // Ralph tracker is not supported for opencode sessions - if (session.mode === 'opencode') { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Ralph tracker is not supported for opencode sessions'); - } - - // Handle reset first (before other config) - if (reset) { - if (reset === 'full') { - session.ralphTracker.fullReset(); - } else { - session.ralphTracker.reset(); - } - } - - // Configure auto-enable behavior - if (disableAutoEnable !== undefined) { - if (disableAutoEnable) { - session.ralphTracker.disableAutoEnable(); - } else { - session.ralphTracker.enableAutoEnable(); - } - } - - // Enable/disable the tracker - if (enabled !== undefined) { - if (enabled) { - session.ralphTracker.enable(); - // Allow re-enabling on restart if user explicitly enabled - session.ralphTracker.enableAutoEnable(); - } else { - session.ralphTracker.disable(); - // Prevent re-enabling on restart when user explicitly disabled - session.ralphTracker.disableAutoEnable(); - } - // Persist Ralph enabled state - this.mux.updateRalphEnabled(id, enabled); - } - - // Configure the Ralph tracker - if (completionPhrase !== undefined) { - // Start loop with completion phrase to set it up for watching - if (completionPhrase) { - session.ralphTracker.startLoop(completionPhrase, maxIterations || undefined); - } - } - - if (maxIterations !== undefined) { - session.ralphTracker.setMaxIterations(maxIterations || null); - } - - // Persist and broadcast the update - this.persistSessionState(session); - this.broadcast('session:ralphLoopUpdate', { - sessionId: id, - state: session.ralphLoopState, - }); - - return { success: true }; - }); - - // Reset circuit breaker for Ralph tracker - this.app.post('/api/sessions/:id/ralph-circuit-breaker/reset', async (req) => { - const { id } = req.params as { id: string }; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - session.ralphTracker.resetCircuitBreaker(); - return { success: true }; - }); - - // Get Ralph status block and circuit breaker state - this.app.get('/api/sessions/:id/ralph-status', async (req) => { - const { id } = req.params as { id: string }; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - return { - success: true, - data: { - lastStatusBlock: session.ralphTracker.lastStatusBlock, - circuitBreaker: session.ralphTracker.circuitBreakerStatus, - cumulativeStats: session.ralphTracker.cumulativeStats, - exitGateMet: session.ralphTracker.exitGateMet, - }, - }; - }); - - // Generate @fix_plan.md content from todos - this.app.get('/api/sessions/:id/fix-plan', async (req) => { - const { id } = req.params as { id: string }; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - const content = session.ralphTracker.generateFixPlanMarkdown(); - return { - success: true, - data: { - content, - todoCount: session.ralphTracker.todos.length, - }, - }; - }); - - // Import todos from @fix_plan.md content - this.app.post('/api/sessions/:id/fix-plan/import', async (req) => { - const { id } = req.params as { id: string }; - const importResult = FixPlanImportSchema.safeParse(req.body); - if (!importResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const { content } = importResult.data; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - const importedCount = session.ralphTracker.importFixPlanMarkdown(content); - this.persistSessionState(session); - - return { - success: true, - data: { - importedCount, - todos: session.ralphTracker.todos, - }, - }; - }); - - // Write @fix_plan.md to session's working directory - this.app.post('/api/sessions/:id/fix-plan/write', async (req) => { - const { id } = req.params as { id: string }; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - const workingDir = session.workingDir; - if (!workingDir) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Session has no working directory'); - } - - const content = session.ralphTracker.generateFixPlanMarkdown(); - const filePath = join(workingDir, '@fix_plan.md'); - - try { - await fs.writeFile(filePath, content, 'utf-8'); - return { - success: true, - data: { - filePath, - todoCount: session.ralphTracker.todos.length, - }, - }; - } catch (error) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to write file: ${error}`); - } - }); - - // Read @fix_plan.md from session's working directory and import - this.app.post('/api/sessions/:id/fix-plan/read', async (req) => { - const { id } = req.params as { id: string }; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - const workingDir = session.workingDir; - if (!workingDir) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Session has no working directory'); - } - - const filePath = join(workingDir, '@fix_plan.md'); - - try { - const content = await fs.readFile(filePath, 'utf-8'); - const importedCount = session.ralphTracker.importFixPlanMarkdown(content); - this.persistSessionState(session); - - return { - success: true, - data: { - filePath, - importedCount, - todos: session.ralphTracker.todos, - }, - }; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return createErrorResponse(ApiErrorCode.NOT_FOUND, '@fix_plan.md not found in working directory'); - } - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${error}`); - } - }); - - // Write Ralph prompt to file in session's working directory - // This avoids mux input escaping issues with long multi-line prompts - this.app.post('/api/sessions/:id/ralph-prompt/write', async (req) => { - const { id } = req.params as { id: string }; - const promptResult = RalphPromptWriteSchema.safeParse(req.body); - if (!promptResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const { content } = promptResult.data; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - const workingDir = session.workingDir; - if (!workingDir) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Session has no working directory'); - } - - const filePath = join(workingDir, '@ralph_prompt.md'); - - try { - await fs.writeFile(filePath, content, 'utf-8'); - return { - success: true, - data: { - filePath, - contentLength: content.length, - }, - }; - } catch (error) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to write file: ${error}`); - } - }); - - // Run prompt in session - this.app.post('/api/sessions/:id/run', async (req): Promise => { - const { id } = req.params as { id: string }; - const result = RunPromptSchema.safeParse(req.body); - if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); - } - const { prompt } = result.data; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - if (session.isBusy()) { - return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy'); - } - - // Run async, don't wait - session.runPrompt(prompt).catch((err) => { - this.broadcast('session:error', { id, error: err.message }); - }); - - this.broadcast('session:running', { id, prompt }); - return { success: true }; - }); - - // Start interactive Claude session (persists even if browser disconnects) - this.app.post('/api/sessions/:id/interactive', async (req): Promise => { - const { id } = req.params as { id: string }; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - if (session.isBusy()) { - return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy'); - } - - try { - // Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user) - // Ralph tracker is not supported for opencode sessions - if ( - session.mode !== 'opencode' && - this.store.getConfig().ralphEnabled && - !session.ralphTracker.autoEnableDisabled - ) { - autoConfigureRalph(session, session.workingDir, () => {}); - if (!session.ralphTracker.enabled) { - session.ralphTracker.enable(); - } - } - - await session.startInteractive(); - getLifecycleLog().log({ - event: 'started', - sessionId: id, - name: session.name, - mode: session.mode, - }); - this.broadcast('session:interactive', { id }); - this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) }); - - return { success: true }; - } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); - } - }); - - // Start a plain shell session (no Claude) - this.app.post('/api/sessions/:id/shell', async (req): Promise => { - const { id } = req.params as { id: string }; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - if (session.isBusy()) { - return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy'); - } - - try { - await session.startShell(); - getLifecycleLog().log({ - event: 'started', - sessionId: id, - name: session.name, - mode: 'shell', - }); - this.broadcast('session:interactive', { id, mode: 'shell' }); - this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) }); - return { success: true }; - } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); - } - }); - - // Send input to interactive session - // useMux: true uses writeViaMux which is more reliable for programmatic input - this.app.post('/api/sessions/:id/input', async (req): Promise => { - const { id } = req.params as { id: string }; - const result = SessionInputWithLimitSchema.safeParse(req.body); - if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); - } - const { input, useMux } = result.data; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - const inputStr = String(input); - if (inputStr.length > MAX_INPUT_LENGTH) { - return createErrorResponse( - ApiErrorCode.INVALID_INPUT, - `Input exceeds maximum length (${MAX_INPUT_LENGTH} bytes)` - ); - } - - // Write input to PTY. Direct write is synchronous; writeViaMux - // (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response. - if (useMux) { - // Fire-and-forget: don't block HTTP response on tmux child process. - // Fallback to direct write on failure. - session - .writeViaMux(inputStr) - .then((ok) => { - if (!ok) { - console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`); - session.write(inputStr); - } - }) - .catch(() => { - session.write(inputStr); - }); + // Global error handler for structured errors thrown by findSessionOrFail + this.app.setErrorHandler((error, _req, reply) => { + const statusCode = (error as { statusCode?: number }).statusCode ?? 500; + const body = (error as { body?: unknown }).body; + if (body) { + reply.code(statusCode).send(body); } else { - session.write(inputStr); - } - return { success: true }; - }); - - // Resize session terminal - this.app.post('/api/sessions/:id/resize', async (req): Promise => { - const { id } = req.params as { id: string }; - const result = ResizeSchema.safeParse(req.body); - if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); - } - const { cols, rows } = result.data; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - // Note: Zod already validates that cols and rows are positive integers within bounds - if (cols > MAX_TERMINAL_COLS || rows > MAX_TERMINAL_ROWS) { - return createErrorResponse( - ApiErrorCode.INVALID_INPUT, - `Terminal dimensions exceed maximum (${MAX_TERMINAL_COLS}x${MAX_TERMINAL_ROWS})` - ); - } - - session.resize(cols, rows); - return { success: true }; - }); - - // Get session terminal buffer (for reconnecting) - // Query params: - // tail= - Only return last N bytes (faster initial load) - this.app.get('/api/sessions/:id/terminal', async (req) => { - const { id } = req.params as { id: string }; - const query = req.query as { tail?: string }; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - const tailBytes = query.tail ? parseInt(query.tail, 10) : 0; - const fullSize = session.terminalBufferLength; - let truncated = false; - let cleanBuffer: string; - - if (tailBytes > 0 && fullSize > tailBytes) { - // Fast path: tail from the end, skip expensive banner search on full 2MB buffer. - // Banner is near the top and gets discarded by tail anyway. - cleanBuffer = session.terminalBuffer.slice(-tailBytes); - truncated = true; - // Avoid starting mid-ANSI-escape: find first newline within the first 4KB - // and start from there. This prevents xterm.js from parsing a partial escape - // sequence which corrupts cursor position for all subsequent Ink redraws. - const firstNewline = cleanBuffer.indexOf('\n'); - if (firstNewline > 0 && firstNewline < 4096) { - cleanBuffer = cleanBuffer.slice(firstNewline + 1); - } - } else { - // Full buffer: clean junk before actual Claude content - cleanBuffer = session.terminalBuffer; - - // Find where Claude banner starts (has color codes before "Claude") - const claudeMatch = cleanBuffer.match(CLAUDE_BANNER_PATTERN); - if (claudeMatch && claudeMatch.index !== undefined && claudeMatch.index > 0) { - let lineStart = claudeMatch.index; - while (lineStart > 0 && cleanBuffer[lineStart - 1] !== '\n') { - lineStart--; - } - cleanBuffer = cleanBuffer.slice(lineStart); - } - } - - // Remove Ctrl+L and leading whitespace (cheap on tailed subset) - cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '').replace(LEADING_WHITESPACE_PATTERN, ''); - - return { - terminalBuffer: cleanBuffer, - status: session.status, - fullSize, - truncated, - }; - }); - - // ============ Respawn Controller Endpoints ============ - - // Get respawn status for a session - this.app.get('/api/sessions/:id/respawn', async (req) => { - const { id } = req.params as { id: string }; - const controller = this.respawnControllers.get(id); - - if (!controller) { - return { enabled: false, status: null }; - } - - return { - enabled: true, - ...controller.getStatus(), - }; - }); - - // Get respawn config (from running controller or pre-saved) - this.app.get('/api/sessions/:id/respawn/config', async (req) => { - const { id } = req.params as { id: string }; - const controller = this.respawnControllers.get(id); - - if (controller) { - return { success: true, config: controller.getConfig(), active: true }; - } - - // Return pre-saved config from mux-sessions.json - const preConfig = this.mux.getSession(id)?.respawnConfig; - if (preConfig) { - return { success: true, config: preConfig, active: false }; - } - - return { success: true, config: null, active: false }; - }); - - // Start respawn controller for a session - this.app.post('/api/sessions/:id/respawn/start', async (req) => { - const { id } = req.params as { id: string }; - let body: Partial | undefined; - if (req.body) { - const result = RespawnConfigSchema.safeParse(req.body); - if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid respawn config'); - } - body = result.data as Partial; - } - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - // Respawn is not supported for opencode sessions - if (session.mode === 'opencode') { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions'); - } - - // Create or get existing controller - let controller = this.respawnControllers.get(id); - if (!controller) { - // Merge request body with pre-saved config from mux-sessions.json - const preConfig = this.mux.getSession(id)?.respawnConfig; - const config = body || preConfig ? { ...preConfig, ...body } : undefined; - controller = new RespawnController(session, config); - this.respawnControllers.set(id, controller); - this.setupRespawnListeners(id, controller); - } else if (body) { - controller.updateConfig(body); - } - - controller.start(); - - // Persist respawn config to mux session and state.json - this.saveRespawnConfig(id, controller.getConfig()); - this.persistSessionState(session); - - this.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() }); - - return { success: true, status: controller.getStatus() }; - }); - - // Stop respawn controller for a session - this.app.post('/api/sessions/:id/respawn/stop', async (req) => { - const { id } = req.params as { id: string }; - const controller = this.respawnControllers.get(id); - - if (!controller) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Respawn controller not found'); - } - - controller.stop(); - - // Remove controller from map so persistSessionState doesn't save respawnEnabled: true - this.respawnControllers.delete(id); - - // Clear any timed respawn - const timerInfo = this.respawnTimers.get(id); - if (timerInfo) { - clearTimeout(timerInfo.timer); - this.respawnTimers.delete(id); - } - - // Clear persisted respawn config - this.mux.clearRespawnConfig(id); - - // Update state.json (respawnConfig removed) - const session = this.sessions.get(id); - if (session) { - this.persistSessionState(session); - } - - this.broadcast('respawn:stopped', { sessionId: id }); - - return { success: true }; - }); - - // Update respawn configuration (works with or without running controller) - this.app.put('/api/sessions/:id/respawn/config', async (req) => { - const { id } = req.params as { id: string }; - // Validate respawn config to prevent arbitrary field injection - const parseResult = RespawnConfigSchema.safeParse(req.body); - if (!parseResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid respawn config: ${parseResult.error.message}`); - } - const config = parseResult.data as Partial; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - const controller = this.respawnControllers.get(id); - - if (controller) { - // Update running controller - controller.updateConfig(config); - this.saveRespawnConfig(id, controller.getConfig()); - this.persistSessionState(session); - this.broadcast('respawn:configUpdated', { sessionId: id, config: controller.getConfig() }); - return { success: true, config: controller.getConfig() }; - } - - // No controller running - save as pre-config for when respawn starts - const existing = this.mux.getSession(id); - const currentConfig = existing?.respawnConfig; - const merged: PersistedRespawnConfig = { - enabled: config.enabled ?? currentConfig?.enabled ?? false, - idleTimeoutMs: config.idleTimeoutMs ?? currentConfig?.idleTimeoutMs ?? 10000, - updatePrompt: config.updatePrompt ?? currentConfig?.updatePrompt ?? 'update all the docs and CLAUDE.md', - interStepDelayMs: config.interStepDelayMs ?? currentConfig?.interStepDelayMs ?? 1000, - sendClear: config.sendClear ?? currentConfig?.sendClear ?? true, - sendInit: config.sendInit ?? currentConfig?.sendInit ?? true, - kickstartPrompt: config.kickstartPrompt ?? currentConfig?.kickstartPrompt, - autoAcceptPrompts: config.autoAcceptPrompts ?? currentConfig?.autoAcceptPrompts ?? true, - autoAcceptDelayMs: config.autoAcceptDelayMs ?? currentConfig?.autoAcceptDelayMs ?? 8000, - aiIdleCheckEnabled: config.aiIdleCheckEnabled ?? currentConfig?.aiIdleCheckEnabled ?? true, - aiIdleCheckModel: config.aiIdleCheckModel ?? currentConfig?.aiIdleCheckModel ?? 'claude-opus-4-5-20251101', - aiIdleCheckMaxContext: config.aiIdleCheckMaxContext ?? currentConfig?.aiIdleCheckMaxContext ?? 16000, - aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs ?? 90000, - aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs ?? 180000, - aiPlanCheckEnabled: config.aiPlanCheckEnabled ?? currentConfig?.aiPlanCheckEnabled ?? true, - aiPlanCheckModel: config.aiPlanCheckModel ?? currentConfig?.aiPlanCheckModel ?? 'claude-opus-4-5-20251101', - aiPlanCheckMaxContext: config.aiPlanCheckMaxContext ?? currentConfig?.aiPlanCheckMaxContext ?? 8000, - aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs ?? currentConfig?.aiPlanCheckTimeoutMs ?? 60000, - aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs ?? 30000, - durationMinutes: currentConfig?.durationMinutes, - }; - this.mux.updateRespawnConfig(id, merged); - this.persistSessionState(session); - this.broadcast('respawn:configUpdated', { sessionId: id, config: merged }); - return { success: true, config: merged }; - }); - - // Start interactive session WITH respawn enabled - this.app.post('/api/sessions/:id/interactive-respawn', async (req) => { - const { id } = req.params as { id: string }; - const irResult = req.body ? InteractiveRespawnSchema.safeParse(req.body) : { success: true as const, data: {} }; - if (!irResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const body = irResult.data as { - respawnConfig?: Partial; - durationMinutes?: number; - }; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - if (session.isBusy()) { - return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy'); - } - - // Respawn is not supported for opencode sessions - if (session.mode === 'opencode') { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions'); - } - - try { - // Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user) - if (this.store.getConfig().ralphEnabled && !session.ralphTracker.autoEnableDisabled) { - autoConfigureRalph(session, session.workingDir, () => {}); - if (!session.ralphTracker.enabled) { - session.ralphTracker.enable(); - } - } - - // Start interactive session - await session.startInteractive(); - getLifecycleLog().log({ - event: 'started', - sessionId: id, - name: session.name, - mode: session.mode, - reason: 'interactive_respawn', - }); - this.broadcast('session:interactive', { id }); - this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) }); - - // Create and start respawn controller - const controller = new RespawnController(session, body?.respawnConfig); - this.respawnControllers.set(id, controller); - this.setupRespawnListeners(id, controller); - controller.start(); - - // Set up timed stop if duration specified - if (body?.durationMinutes && body.durationMinutes > 0) { - this.setupTimedRespawn(id, body.durationMinutes); - } - - // Persist full session state with respawn config - this.persistSessionState(session); - - this.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() }); - - return { - success: true, - data: { - message: 'Interactive session with respawn started', - respawnStatus: controller.getStatus(), - }, - }; - } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); - } - }); - - // Enable respawn on an EXISTING interactive session - this.app.post('/api/sessions/:id/respawn/enable', async (req) => { - const { id } = req.params as { id: string }; - const reResult = req.body ? RespawnEnableSchema.safeParse(req.body) : { success: true as const, data: {} }; - if (!reResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const body = reResult.data as { config?: Partial; durationMinutes?: number }; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - // Respawn is not supported for opencode sessions - if (session.mode === 'opencode') { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions'); - } - - // Check if session is running (has a PID) - if (!session.pid) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Session is not running. Start it first.'); - } - - // Stop existing controller if any - const existingController = this.respawnControllers.get(id); - if (existingController) { - existingController.stop(); - } - - // Create and start new respawn controller (merge with pre-saved config) - const preConfig = this.mux.getSession(id)?.respawnConfig; - const config = body?.config || preConfig ? { ...preConfig, ...body?.config } : undefined; - const controller = new RespawnController(session, config); - this.respawnControllers.set(id, controller); - this.setupRespawnListeners(id, controller); - controller.start(); - - // Set up timed stop if duration specified - if (body?.durationMinutes && body.durationMinutes > 0) { - this.setupTimedRespawn(id, body.durationMinutes); - } - - // Persist respawn config to mux session and state.json - this.saveRespawnConfig(id, controller.getConfig(), body?.durationMinutes); - this.persistSessionState(session); - - this.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() }); - - return { - success: true, - message: 'Respawn enabled on existing session', - respawnStatus: controller.getStatus(), - }; - }); - - // Set auto-clear on a session - this.app.post('/api/sessions/:id/auto-clear', async (req) => { - const { id } = req.params as { id: string }; - const acResult = AutoClearSchema.safeParse(req.body); - if (!acResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const body = acResult.data; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - session.setAutoClear(body.enabled, body.threshold); - this.persistSessionState(session); - this.broadcast('session:updated', this.getSessionStateWithRespawn(session)); - - return { - success: true, - data: { - autoClear: { - enabled: session.autoClearEnabled, - threshold: session.autoClearThreshold, - }, - }, - }; - }); - - // Set auto-compact on a session - this.app.post('/api/sessions/:id/auto-compact', async (req) => { - const { id } = req.params as { id: string }; - const compactResult = AutoCompactSchema.safeParse(req.body); - if (!compactResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const body = compactResult.data; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - session.setAutoCompact(body.enabled, body.threshold, body.prompt); - this.persistSessionState(session); - this.broadcast('session:updated', this.getSessionStateWithRespawn(session)); - - return { - success: true, - data: { - autoCompact: { - enabled: session.autoCompactEnabled, - threshold: session.autoCompactThreshold, - prompt: session.autoCompactPrompt, - }, - }, - }; - }); - - // Toggle image watcher for a session - this.app.post('/api/sessions/:id/image-watcher', async (req) => { - const { id } = req.params as { id: string }; - const iwResult = ImageWatcherSchema.safeParse(req.body); - if (!iwResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const body = iwResult.data; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - if (body.enabled) { - imageWatcher.watchSession(session.id, session.workingDir); - } else { - imageWatcher.unwatchSession(session.id); - } - - // Store state on session for persistence - session.imageWatcherEnabled = body.enabled; - this.persistSessionState(session); - - return { - success: true, - data: { - imageWatcherEnabled: body.enabled, - }, - }; - }); - - // Toggle flicker filter for a session - this.app.post('/api/sessions/:id/flicker-filter', async (req) => { - const { id } = req.params as { id: string }; - const ffResult = FlickerFilterSchema.safeParse(req.body); - if (!ffResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const body = ffResult.data; - const session = this.sessions.get(id); - - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - session.flickerFilterEnabled = body.enabled; - this.persistSessionState(session); - this.broadcast('session:updated', this.getSessionStateWithRespawn(session)); - - return { - success: true, - data: { - flickerFilterEnabled: body.enabled, - }, - }; - }); - - // Quick run (create session, run prompt, return result, then cleanup) - this.app.post('/api/run', async (req) => { - // Prevent unbounded session creation - if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) { - return createErrorResponse( - ApiErrorCode.SESSION_BUSY, - `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached` - ); - } - - const qrResult = QuickRunSchema.safeParse(req.body); - if (!qrResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const { prompt, workingDir } = qrResult.data; - - if (!prompt.trim()) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required'); - } - const dir = workingDir || process.cwd(); - - // Validate workingDir exists and is a directory - if (workingDir) { - try { - const stat = statSync(dir); - if (!stat.isDirectory()) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory'); - } - } catch { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist'); - } - } - - const session = new Session({ workingDir: dir }); - this.sessions.set(session.id, session); - this.store.incrementSessionsCreated(); - this.persistSessionState(session); - await this.setupSessionListeners(session); - getLifecycleLog().log({ - event: 'created', - sessionId: session.id, - name: session.name, - reason: 'run_prompt', - }); - - this.broadcast('session:created', this.getSessionStateWithRespawn(session)); - - try { - const result = await session.runPrompt(prompt); - // Clean up session after completion to prevent memory leak - await this.cleanupSession(session.id, true, 'run_prompt_complete'); - return { success: true, sessionId: session.id, ...result }; - } catch (err) { - // Clean up session on error too - await this.cleanupSession(session.id, true, 'run_prompt_error'); - return { success: false, sessionId: session.id, error: getErrorMessage(err) }; - } - }); - - // Scheduled runs - this.app.get('/api/scheduled', async () => { - return Array.from(this.scheduledRuns.values()); - }); - - this.app.post( - '/api/scheduled', - async (req): Promise<{ success: boolean; run: ScheduledRun } | ApiResponse> => { - const srResult = ScheduledRunSchema.safeParse(req.body); - if (!srResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const { prompt, workingDir, durationMinutes } = srResult.data; - - // Validate workingDir exists and is a directory - if (workingDir) { - try { - const stat = statSync(workingDir); - if (!stat.isDirectory()) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory'); - } - } catch { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist'); - } - } - - const run = await this.startScheduledRun(prompt, workingDir || process.cwd(), durationMinutes ?? 60); - return { success: true, run }; - } - ); - - this.app.delete('/api/scheduled/:id', async (req) => { - const { id } = req.params as { id: string }; - const run = this.scheduledRuns.get(id); - - if (!run) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled run not found'); - } - - await this.stopScheduledRun(id); - return { success: true }; - }); - - this.app.get('/api/scheduled/:id', async (req) => { - const { id } = req.params as { id: string }; - const run = this.scheduledRuns.get(id); - - if (!run) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled run not found'); - } - - return run; - }); - - // Case management - const casesDir = join(homedir(), 'codeman-cases'); - - this.app.get('/api/cases', async (): Promise => { - const cases: CaseInfo[] = []; - - // Get cases from casesDir - try { - const entries = await fs.readdir(casesDir, { withFileTypes: true }); - for (const e of entries) { - if (e.isDirectory()) { - cases.push({ - name: e.name, - path: join(casesDir, e.name), - hasClaudeMd: existsSync(join(casesDir, e.name, 'CLAUDE.md')), - }); - } - } - } catch { - // casesDir may not exist yet - } - - // Get linked cases - const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); - try { - const linkedCases: Record = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8')); - for (const [name, path] of Object.entries(linkedCases)) { - // Only add if not already in cases (avoid duplicates) and path exists - if (!cases.some((c) => c.name === name) && existsSync(path)) { - cases.push({ - name, - path, - hasClaudeMd: existsSync(join(path, 'CLAUDE.md')), - }); - } - } - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.warn('[Server] Failed to read linked cases:', err); - } - } - - return cases; - }); - - this.app.post('/api/cases', async (req): Promise> => { - const result = CreateCaseSchema.safeParse(req.body); - if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); - } - const { name, description } = result.data; - - const casePath = join(casesDir, name); - - // Security: Path traversal protection - use relative path check - const resolvedPath = resolve(casePath); - const resolvedBase = resolve(casesDir); - const relPath = relative(resolvedBase, resolvedPath); - if (relPath.startsWith('..') || isAbsolute(relPath)) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path'); - } - - if (existsSync(casePath)) { - return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists'); - } - - try { - mkdirSync(casePath, { recursive: true }); - mkdirSync(join(casePath, 'src'), { recursive: true }); - - // Read settings to get custom template path - const templatePath = await this.getDefaultClaudeMdPath(); - const claudeMd = generateClaudeMd(name, description || '', templatePath); - writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd); - - // Write .claude/settings.local.json with hooks for desktop notifications - await writeHooksConfig(casePath); - - this.broadcast('case:created', { name, path: casePath }); - - return { success: true, data: { case: { name, path: casePath } } }; - } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); - } - }); - - // Link an existing folder as a case - this.app.post('/api/cases/link', async (req): Promise> => { - const lcResult = LinkCaseSchema.safeParse(req.body); - if (!lcResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const { name, path: folderPath } = lcResult.data; - - // Expand ~ to home directory - const expandedPath = folderPath.startsWith('~') ? join(homedir(), folderPath.slice(1)) : folderPath; - - // Validate the folder exists - if (!existsSync(expandedPath)) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${expandedPath}`); - } - - // Check if case name already exists in casesDir - const casePath = join(casesDir, name); - if (existsSync(casePath)) { - return createErrorResponse( - ApiErrorCode.ALREADY_EXISTS, - 'A case with this name already exists in codeman-cases.' - ); - } - - // Load existing linked cases - const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); - let linkedCases: Record = {}; - try { - linkedCases = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8')); - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.warn('[Server] Failed to read linked cases:', err); - } - } - - // Check if name is already linked - if (linkedCases[name]) { - return createErrorResponse( - ApiErrorCode.ALREADY_EXISTS, - `Case "${name}" is already linked to ${linkedCases[name]}` - ); - } - - // Save the linked case - linkedCases[name] = expandedPath; - try { - const codemanDir = join(homedir(), '.codeman'); - if (!existsSync(codemanDir)) { - mkdirSync(codemanDir, { recursive: true }); - } - await fs.writeFile(linkedCasesFile, JSON.stringify(linkedCases, null, 2)); - this.broadcast('case:linked', { name, path: expandedPath }); - return { success: true, data: { case: { name, path: expandedPath } } }; - } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); - } - }); - - this.app.get('/api/cases/:name', async (req) => { - const { name } = req.params as { name: string }; - - // First check linked cases - const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); - try { - const linkedCases: Record = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8')); - if (linkedCases[name]) { - const linkedPath = linkedCases[name]; - return { - name, - path: linkedPath, - hasClaudeMd: existsSync(join(linkedPath, 'CLAUDE.md')), - linked: true, - }; - } - } catch { - // ENOENT or parse errors - fall through to casesDir check - } - - // Then check casesDir - const casePath = join(casesDir, name); - - if (!existsSync(casePath)) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found'); - } - - return { - name, - path: casePath, - hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')), - }; - }); - - // Read @fix_plan.md from a case directory (for wizard to detect existing plans) - this.app.get('/api/cases/:name/fix-plan', async (req) => { - const { name } = req.params as { name: string }; - - // Get case path (check linked cases first, then casesDir) - let casePath: string | null = null; - - const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); - try { - const linkedCases: Record = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8')); - if (linkedCases[name]) { - casePath = linkedCases[name]; - } - } catch { - // ENOENT or parse errors - fall through to casesDir - } - - if (!casePath) { - casePath = join(casesDir, name); - } - - const fixPlanPath = join(casePath, '@fix_plan.md'); - - if (!existsSync(fixPlanPath)) { - return { success: true, exists: false, content: null, todos: [] }; - } - - try { - const content = await fs.readFile(fixPlanPath, 'utf-8'); - - // Parse todos from the content (similar to ralph-tracker's importFixPlanMarkdown) - const todos: Array<{ - content: string; - status: 'pending' | 'in_progress' | 'completed'; - priority: string | null; - }> = []; - const todoPattern = /^-\s*\[([ xX-])\]\s*(.+)$/; - const p0HeaderPattern = /^##\s*(High Priority|Critical|P0|Critical Path)/i; - const p1HeaderPattern = /^##\s*(Standard|P1|Medium Priority)/i; - const p2HeaderPattern = /^##\s*(Nice to Have|P2|Low Priority)/i; - const completedHeaderPattern = /^##\s*Completed/i; - - let currentPriority: string | null = null; - let inCompletedSection = false; - - for (const line of content.split('\n')) { - const trimmed = line.trim(); - - if (p0HeaderPattern.test(trimmed)) { - currentPriority = 'P0'; - inCompletedSection = false; - continue; - } - if (p1HeaderPattern.test(trimmed)) { - currentPriority = 'P1'; - inCompletedSection = false; - continue; - } - if (p2HeaderPattern.test(trimmed)) { - currentPriority = 'P2'; - inCompletedSection = false; - continue; - } - if (completedHeaderPattern.test(trimmed)) { - inCompletedSection = true; - continue; - } - - const match = trimmed.match(todoPattern); - if (match) { - const [, checkboxState, taskContent] = match; - let status: 'pending' | 'in_progress' | 'completed'; - - if (inCompletedSection || checkboxState === 'x' || checkboxState === 'X') { - status = 'completed'; - } else if (checkboxState === '-') { - status = 'in_progress'; - } else { - status = 'pending'; - } - - todos.push({ - content: taskContent.trim(), - status, - priority: inCompletedSection ? null : currentPriority, - }); - } - } - - // Calculate stats in a single pass for better performance - let pending = 0, - inProgress = 0, - completed = 0; - for (const t of todos) { - if (t.status === 'pending') pending++; - else if (t.status === 'in_progress') inProgress++; - else if (t.status === 'completed') completed++; - } - const stats = { total: todos.length, pending, inProgress, completed }; - - return { - success: true, - exists: true, - content, - todos, - stats, - }; - } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read @fix_plan.md: ${err}`); - } - }); - - // Quick Start: Create case (if needed) and start interactive session in one click - this.app.post('/api/quick-start', async (req): Promise => { - // Prevent unbounded session creation - if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) { - return createErrorResponse( - ApiErrorCode.SESSION_BUSY, - `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.` - ); - } - - const result = QuickStartSchema.safeParse(req.body); - if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); - } - const { caseName = 'testcase', mode = 'claude', openCodeConfig } = result.data; - - // Check OpenCode availability if requested - if (mode === 'opencode') { - const { isOpenCodeAvailable } = await import('../utils/opencode-cli-resolver.js'); - if (!isOpenCodeAvailable()) { - return createErrorResponse( - ApiErrorCode.OPERATION_FAILED, - 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash' - ); - } - } - - const casePath = join(casesDir, caseName); - - // Security: Path traversal protection - use relative path check - const resolvedPath = resolve(casePath); - const resolvedBase = resolve(casesDir); - const relPath = relative(resolvedBase, resolvedPath); - if (relPath.startsWith('..') || isAbsolute(relPath)) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path'); - } - - // Create case folder and CLAUDE.md if it doesn't exist - if (!existsSync(casePath)) { - try { - mkdirSync(casePath, { recursive: true }); - mkdirSync(join(casePath, 'src'), { recursive: true }); - - // Read settings to get custom template path - const templatePath = await this.getDefaultClaudeMdPath(); - const claudeMd = generateClaudeMd(caseName, '', templatePath); - writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd); - - // Write .claude/settings.local.json with hooks for desktop notifications - // (Claude-specific — OpenCode uses its own plugin system) - if (mode !== 'opencode') { - await writeHooksConfig(casePath); - } - - this.broadcast('case:created', { name: caseName, path: casePath }); - } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`); - } - } - - // Create a new session with the case as working directory - // Apply global Nice priority config and model config from settings - const niceConfig = await this.getGlobalNiceConfig(); - const qsModelConfig = await this.getModelConfig(); - const qsModel = - mode === 'opencode' ? openCodeConfig?.model : mode !== 'shell' ? qsModelConfig?.defaultModel : undefined; - const qsClaudeModeConfig = await this.getClaudeModeConfig(); - const session = new Session({ - workingDir: casePath, - mux: this.mux, - useMux: true, - mode: mode, - niceConfig: niceConfig, - model: qsModel, - claudeMode: qsClaudeModeConfig.claudeMode, - allowedTools: qsClaudeModeConfig.allowedTools, - openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined, - }); - - // Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting - // so the initial state already has the phrase configured (only if globally enabled) - if (mode === 'claude' && this.store.getConfig().ralphEnabled) { - autoConfigureRalph(session, casePath, () => {}); // no broadcast yet - if (!session.ralphTracker.enabled) { - session.ralphTracker.enable(); - session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart - } - } - - this.sessions.set(session.id, session); - this.store.incrementSessionsCreated(); - this.persistSessionState(session); - await this.setupSessionListeners(session); - getLifecycleLog().log({ - event: 'created', - sessionId: session.id, - name: session.name, - reason: 'quick_start', - }); - this.broadcast('session:created', this.getSessionStateWithRespawn(session)); - - // Start in the appropriate mode - try { - if (mode === 'shell') { - await session.startShell(); - getLifecycleLog().log({ - event: 'started', - sessionId: session.id, - name: session.name, - mode: 'shell', - }); - this.broadcast('session:interactive', { id: session.id, mode: 'shell' }); - } else { - // Both 'claude' and 'opencode' modes use startInteractive() - await session.startInteractive(); - getLifecycleLog().log({ - event: 'started', - sessionId: session.id, - name: session.name, - mode, - }); - this.broadcast('session:interactive', { id: session.id, mode }); - } - this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) }); - - // Save lastUsedCase to settings for TUI/web sync - try { - const settingsFilePath = join(homedir(), '.codeman', 'settings.json'); - let settings: Record = {}; - try { - settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8')); - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; - } - settings.lastUsedCase = caseName; - const dir = dirname(settingsFilePath); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - // Use async write to avoid blocking event loop - fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => { - // Non-critical but log for debugging - console.warn('[Server] Failed to save settings (lastUsedCase):', err); - }); - } catch (err) { - // Non-critical but log for debugging - console.warn('[Server] Failed to prepare settings update:', err); - } - - return { - success: true, - sessionId: session.id, - casePath, - caseName, - }; - } catch (err) { - // Clean up session on error to prevent orphaned resources - await this.cleanupSession(session.id, true, 'quick_start_error'); - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); - } - }); - - // ========== Ralph Loop Start (replaces 6-8 serial API calls from frontend) ========== - - this.app.post('/api/ralph-loop/start', async (req): Promise => { - // Prevent unbounded session creation - if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) { - return createErrorResponse( - ApiErrorCode.SESSION_BUSY, - `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.` - ); - } - - const rlResult = RalphLoopStartSchema.safeParse(req.body); - if (!rlResult.success) { - return createErrorResponse( - ApiErrorCode.INVALID_INPUT, - rlResult.error.issues[0]?.message ?? 'Validation failed' - ); - } - const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems } = rlResult.data; - - const casePath = join(casesDir, caseName); - - // Security: Path traversal protection - const rlResolvedPath = resolve(casePath); - const rlResolvedBase = resolve(casesDir); - const rlRelPath = relative(rlResolvedBase, rlResolvedPath); - if (rlRelPath.startsWith('..') || isAbsolute(rlRelPath)) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path'); - } - - // Create case folder if it doesn't exist (reuse quick-start logic) - if (!existsSync(casePath)) { - try { - mkdirSync(casePath, { recursive: true }); - mkdirSync(join(casePath, 'src'), { recursive: true }); - const templatePath = await this.getDefaultClaudeMdPath(); - const claudeMd = generateClaudeMd(caseName, '', templatePath); - writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd); - await writeHooksConfig(casePath); - this.broadcast('case:created', { name: caseName, path: casePath }); - } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`); - } - } - - // Create session - const niceConfig = await this.getGlobalNiceConfig(); - const rlModelConfig = await this.getModelConfig(); - const rlClaudeModeConfig = await this.getClaudeModeConfig(); - const session = new Session({ - workingDir: casePath, - mux: this.mux, - useMux: true, - mode: 'claude', - niceConfig, - model: rlModelConfig?.defaultModel, - claudeMode: rlClaudeModeConfig.claudeMode, - allowedTools: rlClaudeModeConfig.allowedTools, - }); - - // Configure Ralph tracker - autoConfigureRalph(session, casePath, () => {}); - if (!session.ralphTracker.enabled) { - session.ralphTracker.enable(); - session.ralphTracker.enableAutoEnable(); - } - session.ralphTracker.startLoop(completionPhrase, maxIterations ?? undefined); - - // Build fix_plan markdown from plan items if provided - const enabledItems = planItems?.filter((i) => i.enabled) ?? []; - let planContent = ''; - if (enabledItems.length > 0) { - const p0 = enabledItems.filter((i) => i.priority === 'P0'); - const p1 = enabledItems.filter((i) => i.priority === 'P1'); - const p2 = enabledItems.filter((i) => i.priority === 'P2'); - const noPri = enabledItems.filter((i) => !i.priority); - planContent = '# Implementation Plan\n\n'; - planContent += `Generated: ${new Date().toISOString().slice(0, 10)}\n\n`; - if (p0.length > 0) { - planContent += '## Critical Path (P0)\n\n'; - p0.forEach((i) => { - planContent += `- [ ] ${i.content}\n`; - }); - planContent += '\n'; - } - if (p1.length > 0) { - planContent += '## Standard (P1)\n\n'; - p1.forEach((i) => { - planContent += `- [ ] ${i.content}\n`; - }); - planContent += '\n'; - } - if (p2.length > 0) { - planContent += '## Nice-to-Have (P2)\n\n'; - p2.forEach((i) => { - planContent += `- [ ] ${i.content}\n`; - }); - planContent += '\n'; - } - if (noPri.length > 0) { - planContent += '## Tasks\n\n'; - noPri.forEach((i) => { - planContent += `- [ ] ${i.content}\n`; - }); - planContent += '\n'; - } - - // Import into tracker and write to disk - session.ralphTracker.importFixPlanMarkdown(planContent); - const fixPlanPath = join(casePath, '@fix_plan.md'); - writeFileSync(fixPlanPath, planContent, 'utf-8'); - } - - // Build full prompt - const hasPlan = enabledItems.length > 0; - let fullPrompt = taskDescription + '\n\n---\n\n'; - if (hasPlan) { - fullPrompt += '## Task Plan\n\n'; - fullPrompt += 'A task plan has been written to `@fix_plan.md`. Use this to track progress:\n'; - fullPrompt += '- Reference the plan at the start of each iteration\n'; - fullPrompt += '- Update task checkboxes as you complete items\n'; - fullPrompt += '- Work through items in priority order (P0 > P1 > P2)\n\n'; - } - fullPrompt += '## Iteration Protocol\n\n'; - fullPrompt += 'This is an autonomous loop. Files from previous iterations persist. On each iteration:\n'; - fullPrompt += '1. Check what work has already been done\n'; - fullPrompt += '2. Make incremental progress toward completion\n'; - fullPrompt += '3. Commit meaningful changes with descriptive messages\n\n'; - fullPrompt += '## Verification\n\n'; - fullPrompt += 'After each significant change:\n'; - fullPrompt += '- Run tests to verify (npm test, pytest, etc.)\n'; - fullPrompt += '- Check for type/lint errors if applicable\n'; - fullPrompt += '- If tests fail, read the error, fix it, and retry\n\n'; - fullPrompt += '## Completion Criteria\n\n'; - fullPrompt += `Output \`${completionPhrase}\` when ALL of the following are true:\n`; - fullPrompt += '- All requirements from the task description are implemented\n'; - fullPrompt += '- All tests pass\n'; - fullPrompt += '- Changes are committed\n\n'; - fullPrompt += '## If Stuck\n\n'; - fullPrompt += 'If you encounter the same error for 3+ iterations:\n'; - fullPrompt += "1. Document what you've tried\n"; - fullPrompt += '2. Identify the specific blocker\n'; - fullPrompt += '3. Try an alternative approach\n'; - fullPrompt += '4. If truly blocked, output `BLOCKED` with an explanation\n'; - - // Write prompt to file - const promptPath = join(casePath, '@ralph_prompt.md'); - writeFileSync(promptPath, fullPrompt, 'utf-8'); - - // Register session - this.sessions.set(session.id, session); - this.store.incrementSessionsCreated(); - this.persistSessionState(session); - await this.setupSessionListeners(session); - getLifecycleLog().log({ - event: 'created', - sessionId: session.id, - name: session.name, - reason: 'ralph_loop_start', - }); - this.broadcast('session:created', this.getSessionStateWithRespawn(session)); - - // Start interactive mode - try { - await session.startInteractive(); - getLifecycleLog().log({ - event: 'started', - sessionId: session.id, - name: session.name, - mode: 'claude', - }); - this.broadcast('session:interactive', { id: session.id, mode: 'claude' }); - this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) }); - } catch (err) { - await this.cleanupSession(session.id, true, 'ralph_loop_start_error'); - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); - } - - // Enable respawn if requested - if (enableRespawn) { - const ralphUpdatePrompt = - 'Before /clear: Update CLAUDE.md with discoveries and notes, mark completed tasks in @fix_plan.md, write a brief progress summary to a file so the next iteration can continue seamlessly.'; - const ralphKickstartPrompt = `You are in a Ralph Wiggum loop. Read @fix_plan.md for task status, continue on the next uncompleted task, output ${completionPhrase} when ALL tasks are complete.`; - const controller = new RespawnController(session, { - updatePrompt: ralphUpdatePrompt, - sendClear: true, - sendInit: true, - kickstartPrompt: ralphKickstartPrompt, - }); - this.respawnControllers.set(session.id, controller); - this.setupRespawnListeners(session.id, controller); - controller.start(); - this.saveRespawnConfig(session.id, controller.getConfig()); - this.persistSessionState(session); - this.broadcast('respawn:started', { - sessionId: session.id, - status: controller.getStatus(), - }); - } - - // Save lastUsedCase - try { - const settingsFilePath = join(homedir(), '.codeman', 'settings.json'); - let settings: Record = {}; - try { - settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8')); - } catch { - /* ignore */ - } - settings.lastUsedCase = caseName; - const dir = dirname(settingsFilePath); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch(() => {}); - } catch { - /* non-critical */ - } - - const sessionId = session.id; - - // Async: poll for CLI readiness, then send prompt - setImmediate(() => { - const pollReady = async () => { - for (let attempt = 0; attempt < 60; attempt++) { - await new Promise((r) => setTimeout(r, 500)); - const s = this.sessions.get(sessionId); - if (!s) return; // session was deleted - // Check terminal output for prompt indicator - const termBuf = s.getTerminalBuffer().slice(-2048); - if (termBuf.includes('❯') || termBuf.includes('tokens')) { - break; - } - } - // Small extra delay for CLI to settle - await new Promise((r) => setTimeout(r, 2000)); - const s = this.sessions.get(sessionId); - if (!s) return; - try { - await s.writeViaMux('Read @ralph_prompt.md and follow the instructions. Start working immediately.\r'); - } catch (err) { - console.warn(`[RalphLoop] Failed to send prompt to session ${sessionId}:`, getErrorMessage(err)); - } - }; - pollReady().catch((err) => console.error('[RalphLoop] pollReady error:', err)); - }); - - return { - success: true, - data: { sessionId, caseName }, - }; - }); - - // Use enhanced PlanItem from orchestrator (has verification, dependencies, tracking) - type PlanItem = import('../plan-orchestrator.js').PlanItem; - - this.app.post('/api/generate-plan', async (req): Promise => { - const gpResult = GeneratePlanSchema.safeParse(req.body); - if (!gpResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const { taskDescription, detailLevel = 'standard' } = gpResult.data; - - // Build sophisticated prompt based on Ralph Wiggum methodology - const detailConfig = { - brief: { style: 'high-level milestones', testDepth: 'basic' }, - standard: { style: 'balanced implementation steps', testDepth: 'thorough' }, - detailed: { - style: 'granular sub-tasks with full TDD coverage', - testDepth: 'comprehensive', - }, - }; - const levelConfig = detailConfig[detailLevel] || detailConfig.standard; - - const prompt = `You are an expert software architect breaking down a task into a thorough implementation plan. - -## TASK TO IMPLEMENT -${taskDescription} - -## YOUR MISSION -Create a detailed, actionable implementation plan following Test-Driven Development (TDD) methodology. -Think deeply about: -- What are ALL the components, modules, and features needed? -- What could go wrong? Add defensive steps for error handling. -- How will we verify each part works? Tests before implementation. -- What edge cases need handling? -- What's the logical order of dependencies? - -## DETAIL LEVEL: ${detailLevel.toUpperCase()} -Style: ${levelConfig.style} -Generate as many steps as needed to properly cover the task - don't artificially limit yourself. -For complex projects, this could be 30, 50, or even 100+ steps. Quality over brevity. - -## PLAN STRUCTURE - -Your plan MUST include these phases in order: - -### Phase 1: Foundation & Setup -- Project structure, dependencies, configuration -- Database schemas, type definitions, interfaces - -### Phase 2: Core Implementation (TDD Cycle) -For EACH feature: -1. Write failing tests first (unit tests) -2. Implement the feature -3. Run tests, debug until passing -4. Refactor if needed - -### Phase 3: Integration & Edge Cases -- Integration tests for feature interactions -- Edge case handling (errors, boundaries, invalid input) -- Error messages and user feedback - -### Phase 4: Verification & Hardening -- Run full test suite -- Fix any failing tests -- Add missing test coverage -- Final verification that ALL requirements are met - -## OUTPUT FORMAT -Return ONLY a JSON array. Each item MUST have: -- id: unique identifier (e.g., "P0-001", "P1-002") -- content: specific action (verb phrase, 15-120 chars, be descriptive!) -- priority: "P0" (critical/blocking), "P1" (required), "P2" (enhancement) -- verificationCriteria: HOW to verify this step is complete (required!) -- tddPhase: "setup" | "test" | "impl" | "verify" -- dependencies: array of task IDs this depends on (empty if none) - -## EXAMPLE OUTPUT -[ - {"id": "P0-001", "content": "Create project structure with src/, tests/, and config directories", "priority": "P0", "verificationCriteria": "Directories exist, package.json initialized", "tddPhase": "setup", "dependencies": []}, - {"id": "P0-002", "content": "Define TypeScript interfaces for User, Session, and AuthToken types", "priority": "P0", "verificationCriteria": "Types compile without errors, exported from types.ts", "tddPhase": "setup", "dependencies": ["P0-001"]}, - {"id": "P0-003", "content": "Write failing unit tests for password hashing (valid password, empty, too short)", "priority": "P0", "verificationCriteria": "Tests exist, fail with 'not implemented'", "tddPhase": "test", "dependencies": ["P0-002"]}, - {"id": "P0-004", "content": "Implement password hashing with bcrypt, configurable salt rounds", "priority": "P0", "verificationCriteria": "npm test -- --grep='password' passes", "tddPhase": "impl", "dependencies": ["P0-003"]}, - {"id": "P0-005", "content": "Write failing tests for JWT token generation and validation", "priority": "P0", "verificationCriteria": "Tests exist, fail with 'not implemented'", "tddPhase": "test", "dependencies": ["P0-004"]}, - {"id": "P0-006", "content": "Implement JWT service with access/refresh token support", "priority": "P0", "verificationCriteria": "npm test -- --grep='JWT' passes", "tddPhase": "impl", "dependencies": ["P0-005"]}, - {"id": "P1-001", "content": "Write integration tests for login flow (valid creds, invalid, locked account)", "priority": "P1", "verificationCriteria": "Integration tests exist, fail until endpoint implemented", "tddPhase": "test", "dependencies": ["P0-006"]}, - {"id": "P1-002", "content": "Implement login endpoint with rate limiting and audit logging", "priority": "P1", "verificationCriteria": "All login tests pass, endpoint returns 200/401 correctly", "tddPhase": "impl", "dependencies": ["P1-001"]}, - {"id": "P1-003", "content": "Run full test suite and verify all tests pass", "priority": "P1", "verificationCriteria": "npm test exits with code 0, coverage > 80%", "tddPhase": "verify", "dependencies": ["P1-002"]} -] - -## CRITICAL RULES -1. EVERY task MUST have verificationCriteria - this is non-negotiable! -2. EVERY implementation step should have a corresponding test step BEFORE it -3. Use tddPhase: "test" for writing tests, "impl" for implementation -4. Dependencies must form a valid DAG - no cycles -5. Be SPECIFIC - not "Add tests" but "Write tests for X covering Y and Z" -6. End with verification that ALL original requirements are met -7. Use P0 for foundation and core features, P1 for required work, P2 for nice-to-have - -NOW: Generate the implementation plan for the task above. Think step by step.`; - - // Create temporary session for the AI call using Opus 4.5 for deep reasoning - const session = new Session({ - workingDir: process.cwd(), - mux: this.mux, - useMux: false, // No mux needed for one-shot - mode: 'claude', - }); - - // Use configured model for plan generation, falling back to opus - const planModelConfig = await this.getModelConfig(); - const modelToUse = planModelConfig?.agentTypeOverrides?.implement || planModelConfig?.defaultModel || 'opus'; - - try { - const { result, cost } = await session.runPrompt(prompt, { model: modelToUse }); - - // Parse JSON from result - const jsonMatch = result.match(/\[[\s\S]*\]/); - if (!jsonMatch) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Failed to parse plan - no JSON array found'); - } - - let items: PlanItem[]; - try { - const parsed = JSON.parse(jsonMatch[0]); - if (!Array.isArray(parsed)) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Invalid response - expected array'); - } - - // Validate and normalize items with enhanced fields - items = parsed.map((item: unknown, idx: number) => { - if (typeof item !== 'object' || item === null) { - return { - id: `task-${idx}`, - content: `Step ${idx + 1}`, - priority: null, - verificationCriteria: 'Task completed successfully', - status: 'pending' as const, - attempts: 0, - version: 1, - }; - } - const obj = item as Record; - const content = typeof obj.content === 'string' ? obj.content.slice(0, 200) : `Step ${idx + 1}`; - let priority: 'P0' | 'P1' | 'P2' | null = null; - if (obj.priority === 'P0' || obj.priority === 'P1' || obj.priority === 'P2') { - priority = obj.priority; - } - - // Parse tddPhase - let tddPhase: 'setup' | 'test' | 'impl' | 'verify' | undefined; - if ( - obj.tddPhase === 'setup' || - obj.tddPhase === 'test' || - obj.tddPhase === 'impl' || - obj.tddPhase === 'verify' - ) { - tddPhase = obj.tddPhase; - } - - return { - id: obj.id ? String(obj.id) : `task-${idx}`, - content, - priority, - verificationCriteria: - typeof obj.verificationCriteria === 'string' ? obj.verificationCriteria : 'Task completed successfully', - tddPhase, - dependencies: Array.isArray(obj.dependencies) ? obj.dependencies.map(String) : [], - status: 'pending' as const, - attempts: 0, - version: 1, - }; - }); - // No artificial limit - let Claude generate what's needed - } catch (parseErr) { - return createErrorResponse( - ApiErrorCode.OPERATION_FAILED, - 'Failed to parse plan JSON: ' + getErrorMessage(parseErr) - ); - } - - return { - success: true, - data: { items, costUsd: cost }, - }; - } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Plan generation failed: ' + getErrorMessage(err)); - } finally { - // Clean up the temporary session - try { - await session.stop(); - } catch { - // Ignore cleanup errors - } - } - }); - - // Generate detailed implementation plan using subagent orchestration - // This spawns multiple specialist subagents in parallel for thorough analysis - this.app.post('/api/generate-plan-detailed', async (req): Promise => { - const gpdResult = GeneratePlanDetailedSchema.safeParse(req.body); - if (!gpdResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const { taskDescription, caseName } = gpdResult.data; - - // Determine output directory for saving wizard results - let outputDir: string | undefined; - if (caseName) { - const casesDir = join(homedir(), 'codeman-cases'); - const casePath = join(casesDir, caseName); - // Security: Path traversal protection - use relative path check - const resolvedCase = resolve(casePath); - const resolvedBase = resolve(casesDir); - const relPath = relative(resolvedBase, resolvedCase); - if (!relPath.startsWith('..') && !isAbsolute(relPath) && existsSync(casePath)) { - outputDir = join(casePath, 'ralph-wizard'); - - // Clear old ralph-wizard directory to ensure fresh prompts for each generation - // This prevents stale prompts from previous runs being shown when clicking on agents - if (existsSync(outputDir)) { - try { - rmSync(outputDir, { recursive: true, force: true }); - console.log(`[API] Cleared old ralph-wizard directory: ${outputDir}`); - } catch (err) { - console.warn(`[API] Failed to clear ralph-wizard directory:`, err); - } - } - } - } - - const detailedModelConfig = await this.getModelConfig(); - const orchestrator = new PlanOrchestrator(this.mux, process.cwd(), outputDir, detailedModelConfig ?? undefined); - - // Store orchestrator for potential cancellation via API (not on disconnect) - // Plan generation continues even if browser disconnects - only explicit cancel stops it - const orchestratorId = `plan-${Date.now()}`; - this.activePlanOrchestrators.set(orchestratorId, orchestrator); - - // Broadcast the orchestrator ID so frontend can cancel if needed - this.broadcast('plan:started', { orchestratorId }); - - // Track progress for SSE updates - const progressUpdates: Array<{ phase: string; detail: string; timestamp: number }> = []; - const onProgress = (phase: string, detail: string) => { - const update = { phase, detail, timestamp: Date.now() }; - progressUpdates.push(update); - // Broadcast progress to connected clients - this.broadcast('plan:progress', update); - }; - - // Broadcast plan subagent events for UI visibility - const onSubagent = (event: { - type: string; - agentId: string; - agentType: string; - model: string; - status: string; - detail?: string; - itemCount?: number; - durationMs?: number; - error?: string; - }) => { - this.broadcast('plan:subagent', event); - }; - - try { - const result: DetailedPlanResult = await orchestrator.generateDetailedPlan( - taskDescription, - onProgress, - onSubagent - ); - - // Clean up orchestrator from active map - this.activePlanOrchestrators.delete(orchestratorId); - this.broadcast('plan:completed', { orchestratorId, success: result.success }); - - if (!result.success) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, result.error || 'Plan generation failed'); - } - - return { - success: true, - data: { - items: result.items, - costUsd: result.costUsd, - metadata: result.metadata, - progressLog: progressUpdates, - orchestratorId, - }, - }; - } catch (err) { - // Clean up on error too - this.activePlanOrchestrators.delete(orchestratorId); - this.broadcast('plan:completed', { - orchestratorId, - success: false, - error: getErrorMessage(err), - }); - return createErrorResponse( - ApiErrorCode.OPERATION_FAILED, - 'Detailed plan generation failed: ' + getErrorMessage(err) - ); - } - }); - - // Cancel active plan generation - this.app.post('/api/cancel-plan-generation', async (req): Promise => { - const cpResult = CancelPlanSchema.safeParse(req.body); - if (!cpResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const { orchestratorId } = cpResult.data; - - // If specific orchestrator ID provided, cancel just that one - if (orchestratorId) { - const orchestrator = this.activePlanOrchestrators.get(orchestratorId); - if (!orchestrator) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Plan generation not found or already completed'); - } - console.log(`[API] Cancelling plan generation ${orchestratorId}`); - await orchestrator.cancel(); - this.activePlanOrchestrators.delete(orchestratorId); - this.broadcast('plan:cancelled', { orchestratorId }); - return { success: true, data: { cancelled: orchestratorId } }; - } - - // Otherwise cancel all active plan generations - const cancelled: string[] = []; - for (const [id, orchestrator] of this.activePlanOrchestrators) { - console.log(`[API] Cancelling plan generation ${id}`); - await orchestrator.cancel(); - cancelled.push(id); - this.broadcast('plan:cancelled', { orchestratorId: id }); - } - this.activePlanOrchestrators.clear(); - - return { success: true, data: { cancelled } }; - }); - - // Get ralph-wizard files for a case (prompts and results) - this.app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => { - const { caseName } = req.params as { caseName: string }; - const casesDir = join(homedir(), 'codeman-cases'); - let casePath = join(casesDir, caseName); - - // Security: Path traversal protection - use relative path check - const resolvedCase = resolve(casePath); - const resolvedBase = resolve(casesDir); - const relPath = relative(resolvedBase, resolvedCase); - if (relPath.startsWith('..') || isAbsolute(relPath)) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); - } - - // Check linked cases if path doesn't exist - if (!existsSync(casePath)) { - const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); - try { - const linkedCases: Record = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8')); - if (linkedCases[caseName]) { - casePath = linkedCases[caseName]; - } - } catch { - // No linked cases file - } - } - - const wizardDir = join(casePath, 'ralph-wizard'); - - if (!existsSync(wizardDir)) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Ralph wizard directory not found'); - } - - // List all subdirectories and their files - const files: Array<{ agentType: string; promptFile?: string; resultFile?: string }> = []; - const entries = readdirSync(wizardDir, { withFileTypes: true }); - - for (const entry of entries) { - if (entry.isDirectory()) { - const agentDir = join(wizardDir, entry.name); - const agentFiles: { agentType: string; promptFile?: string; resultFile?: string } = { - agentType: entry.name, - }; - - if (existsSync(join(agentDir, 'prompt.md'))) { - agentFiles.promptFile = `${entry.name}/prompt.md`; - } - if (existsSync(join(agentDir, 'result.json'))) { - agentFiles.resultFile = `${entry.name}/result.json`; - } - - if (agentFiles.promptFile || agentFiles.resultFile) { - files.push(agentFiles); - } - } - } - - return { success: true, data: { files, caseName } }; - }); - - // Read a specific ralph-wizard file - // Cache disabled to ensure fresh prompts when starting new plan generations - this.app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req, reply) => { - const { caseName, filePath } = req.params as { caseName: string; filePath: string }; - const casesDir = join(homedir(), 'codeman-cases'); - let casePath = join(casesDir, caseName); - - // Prevent browser caching - prompts change between plan generations - reply.header('Cache-Control', 'no-store, no-cache, must-revalidate'); - reply.header('Pragma', 'no-cache'); - reply.header('Expires', '0'); - - // Security: Path traversal protection for case name - use relative path check - const resolvedCase = resolve(casePath); - const resolvedBase = resolve(casesDir); - const relPath = relative(resolvedBase, resolvedCase); - if (relPath.startsWith('..') || isAbsolute(relPath)) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); - } - - // Check linked cases if path doesn't exist - if (!existsSync(casePath)) { - const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); - try { - const linkedCases: Record = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8')); - if (linkedCases[caseName]) { - casePath = linkedCases[caseName]; - } - } catch { - // No linked cases file - } - } - - const wizardDir = join(casePath, 'ralph-wizard'); - - // Decode the file path (it may be URL encoded) - const decodedPath = decodeURIComponent(filePath); - const fullPath = join(wizardDir, decodedPath); - - // Security: ensure path is within wizard directory - const resolvedPath = resolve(fullPath); - const resolvedWizard = resolve(wizardDir); - if (!resolvedPath.startsWith(resolvedWizard)) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid file path'); - } - - let content: string; - try { - content = await fs.readFile(fullPath, 'utf-8'); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'); - } - throw err; - } - const isJson = filePath.endsWith('.json'); - - // Parse JSON content safely (may contain invalid JSON or unescaped control characters) - let parsed: unknown = null; - if (isJson) { - try { - parsed = JSON.parse(content); - } catch { - // Try repairing common JSON issues (unescaped control characters, trailing commas) - try { - let repaired = content; - // Fix trailing commas before closing brackets - repaired = repaired.replace(/,(\s*[\]}])/g, '$1'); - // Fix unescaped control characters within JSON strings - repaired = repaired.replace(/"([^"\\]|\\.)*"/g, (match) => { - return match - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r') - .replace(/\t/g, '\\t') - .replace( - // eslint-disable-next-line no-control-regex - /[\x00-\x1f]/g, - (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}` - ); - }); - parsed = JSON.parse(repaired); - } catch { - // Still invalid - return null for parsed, content available as raw string - } - } - } - - return { - success: true, - data: { - content, - filePath: decodedPath, - isJson, - parsed, - }, - }; - }); - - // ============ Plan Management Endpoints ============ - // These endpoints support runtime plan adaptation with checkpoints, failure tracking, and versioning - - // Update a specific plan task (status, attempts, errors) - this.app.patch('/api/sessions/:id/plan/task/:taskId', async (req) => { - const { id, taskId } = req.params as { id: string; taskId: string }; - const session = this.sessions.get(id); - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - const tracker = session.ralphTracker; - if (!tracker) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); - } - - const ptuResult = PlanTaskUpdateSchema.safeParse(req.body); - if (!ptuResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const update = ptuResult.data as { - status?: 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked'; - error?: string; - incrementAttempts?: boolean; - }; - - const result = tracker.updatePlanTask(taskId, update); - if (!result.success) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, result.error || 'Task not found'); - } - - this.broadcast('session:planTaskUpdate', { sessionId: id, taskId, update: result.task }); - return { success: true, data: result.task }; - }); - - // Trigger a checkpoint review (at iterations 5, 10, 20, etc.) - this.app.post('/api/sessions/:id/plan/checkpoint', async (req) => { - const { id } = req.params as { id: string }; - const session = this.sessions.get(id); - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - const tracker = session.ralphTracker; - if (!tracker) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); - } - - const checkpoint = tracker.generateCheckpointReview(); - this.broadcast('session:planCheckpoint', { sessionId: id, checkpoint }); - return { success: true, data: checkpoint }; - }); - - // Get plan version history - this.app.get('/api/sessions/:id/plan/history', async (req) => { - const { id } = req.params as { id: string }; - const session = this.sessions.get(id); - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - const tracker = session.ralphTracker; - if (!tracker) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); - } - - return { success: true, data: tracker.getPlanHistory() }; - }); - - // Rollback to a previous plan version - this.app.post('/api/sessions/:id/plan/rollback/:version', async (req) => { - const { id, version } = req.params as { id: string; version: string }; - const session = this.sessions.get(id); - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - const tracker = session.ralphTracker; - if (!tracker) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); - } - - const result = tracker.rollbackToVersion(parseInt(version, 10)); - if (!result.success) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, result.error || 'Version not found'); - } - - this.broadcast('session:planRollback', { sessionId: id, version: parseInt(version, 10) }); - return { success: true, data: result.plan }; - }); - - // Add a new task to the plan (for runtime adaptation) - this.app.post('/api/sessions/:id/plan/task', async (req) => { - const { id } = req.params as { id: string }; - const session = this.sessions.get(id); - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - const tracker = session.ralphTracker; - if (!tracker) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); - } - - const ptaResult = PlanTaskAddSchema.safeParse(req.body); - if (!ptaResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const task = ptaResult.data; - - const result = tracker.addPlanTask(task); - this.broadcast('session:planTaskAdded', { sessionId: id, task: result.task }); - return { success: true, data: result.task }; - }); - - // ============ App Settings Endpoints ============ - const settingsPath = join(homedir(), '.codeman', 'settings.json'); - - this.app.get('/api/settings', async () => { - try { - const content = await fs.readFile(settingsPath, 'utf-8'); - return JSON.parse(content); - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error('Failed to read settings:', err); - } - } - return {}; - }); - - this.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; - - try { - const dir = dirname(settingsPath); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - let existing: Record = {}; - try { - existing = JSON.parse(await fs.readFile(settingsPath, 'utf-8')); - } catch { - /* ignore */ - } - const merged = { ...existing, ...settings }; - await fs.writeFile(settingsPath, 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 this.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 && !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.getUrl()) { - // Tunnel already running — re-emit so the client gets the URL - this.broadcast('tunnel:started', { url: this.tunnelManager.getUrl() }); - console.log('Tunnel already running, re-broadcast URL to client'); - } 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)); - } - }); - - // ============ Model Configuration Endpoints ============ - - this.app.get('/api/execution/model-config', async () => { - try { - const content = await fs.readFile(settingsPath, '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: {} }; - } - }); - - this.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; - - try { - let settings: Record = {}; - try { - const content = await fs.readFile(settingsPath, 'utf-8'); - settings = JSON.parse(content); - } catch { - // File doesn't exist yet, start fresh - } - - settings.modelConfig = modelConfig; - - const dir = dirname(settingsPath); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); - - return { success: true }; - } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); - } - }); - - // ============ CPU Priority Endpoints ============ - - // Get Nice priority config for a session - this.app.get('/api/sessions/:id/cpu-limit', async (req) => { - const { id } = req.params as { id: string }; - const session = this.sessions.get(id); - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - return { - success: true, - nice: session.niceConfig, - }; - }); - - // Update Nice priority config for a session - // Note: Changes only apply to NEW sessions, not running ones - this.app.post('/api/sessions/:id/cpu-limit', async (req) => { - const { id } = req.params as { id: string }; - const session = this.sessions.get(id); - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - const clResult = CpuLimitSchema.safeParse(req.body); - if (!clResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const body = clResult.data as Partial; - - session.setNice(body); - this.persistSessionState(session); - this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) }); - - return { - success: true, - nice: session.niceConfig, - note: 'Nice priority only affects newly created mux sessions, not currently running ones.', - }; - }); - - // ============ Subagent Window State Endpoints ============ - // Persists minimized/open window states for cross-browser sync - const windowStatesPath = join(homedir(), '.codeman', 'subagent-window-states.json'); - - this.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: [] }; - }); - - this.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; - 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 ============ - // Persists which TAB each agent window connects to. - // This is the PERMANENT record of agent -> tab associations. - const parentMapPath = join(homedir(), '.codeman', 'subagent-parents.json'); - - this.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 {}; - }); - - this.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)); - } - }); - - // ============ Mux Session Management Endpoints ============ - - // Get all tracked mux sessions with stats - this.app.get('/api/mux-sessions', async () => { - const sessions = await this.mux.getSessionsWithStats(); - return { - sessions, - muxAvailable: this.mux.isAvailable(), - }; - }); - - // Kill a mux session - this.app.delete('/api/mux-sessions/:sessionId', async (req) => { - const { sessionId } = req.params as { sessionId: string }; - const success = await this.mux.killSession(sessionId); - return { success }; - }); - - // Reconcile mux sessions (find dead ones) - this.app.post('/api/mux-sessions/reconcile', async () => { - const result = await this.mux.reconcileSessions(); - return result; - }); - - // Start stats collection - this.app.post('/api/mux-sessions/stats/start', async () => { - this.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS); - return { success: true }; - }); - - // Stop stats collection - this.app.post('/api/mux-sessions/stats/stop', async () => { - this.mux.stopStatsCollection(); - return { success: true }; - }); - - // System stats endpoint for frontend header display - this.app.get('/api/system/stats', async () => { - return this.getSystemStats(); - }); - - // ========== Subagent Monitoring (Claude Code Background Agents) ========== - - // List all known subagents - this.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 }; - }); - - // Get subagents for a specific session (by working directory) - this.app.get('/api/sessions/:id/subagents', async (req) => { - const { id } = req.params as { id: string }; - const session = this.sessions.get(id); - if (!session) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`); - } - const subagents = subagentWatcher.getSubagentsForSession(session.workingDir); - return { success: true, data: subagents }; - }); - - // Get a specific subagent's info - this.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 }; - }); - - // Get a subagent's transcript - this.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 }; - }); - - // Kill a subagent - this.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'); - }); - - // Trigger cleanup of stale subagents - this.app.post('/api/subagents/cleanup', async () => { - const removed = subagentWatcher.cleanupNow(); - return { success: true, data: { removed, remaining: subagentWatcher.getSubagents().length } }; - }); - - // Clear all tracked subagents (memory only - does not delete files) - this.app.delete('/api/subagents', async () => { - const cleared = subagentWatcher.clearAll(); - return { success: true, data: { cleared } }; - }); - - // ========== Agent Teams ========== - - // List all discovered teams - this.app.get('/api/teams', async () => { - return { success: true, data: this.teamWatcher.getTeams() }; - }); - - // Get tasks for a specific team - this.app.get('/api/teams/:name/tasks', async (req) => { - const { name } = req.params as { name: string }; - return { success: true, data: this.teamWatcher.getTeamTasks(name) }; - }); - - // ========== Hook Events ========== - - this.app.post('/api/hook-event', async (req) => { - const result = HookEventSchema.safeParse(req.body); - if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); - } - const { event, sessionId, data } = result.data; - if (!this.sessions.has(sessionId)) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); - } - - // Signal the respawn controller based on hook event type - const controller = this.respawnControllers.get(sessionId); - if (controller) { - if (event === 'elicitation_dialog') { - // Block auto-accept for question prompts - controller.signalElicitation(); - } else if (event === 'stop') { - // DEFINITIVE idle signal - Claude finished responding - controller.signalStopHook(); - } else if (event === 'idle_prompt') { - // DEFINITIVE idle signal - Claude has been idle for 60+ seconds - controller.signalIdlePrompt(); - } - } - - // Start transcript watching if transcript_path is provided and safe - if (data && 'transcript_path' in data) { - const transcriptPath = String(data.transcript_path); - if (transcriptPath && isValidWorkingDir(transcriptPath)) { - this.startTranscriptWatcher(sessionId, transcriptPath); - } - } - - // Sanitize forwarded data: only include known safe fields, limit size - const safeData = sanitizeHookData(data); - this.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now(), ...safeData }); - - // Send push notifications for hook events - const session = this.sessions.get(sessionId); - const sessionName = session?.name ?? sessionId.slice(0, 8); - this.sendPushNotifications(`hook:${event}`, { sessionId, sessionName, ...safeData }); - - // Track in run summary - const summaryTracker = this.runSummaryTrackers.get(sessionId); - if (summaryTracker) { - summaryTracker.recordHookEvent(event, safeData); - } - - return { success: true }; - }); - - // ========== Web Push ========== - - this.app.get('/api/push/vapid-key', async () => { - return { success: true, data: { publicKey: this.pushStore.getPublicKey() } }; - }); - - this.app.post('/api/push/subscribe', async (req) => { - const result = PushSubscribeSchema.safeParse(req.body); - if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); - } - const { endpoint, keys, userAgent, pushPreferences } = result.data; - const record = this.pushStore.addSubscription({ - id: uuidv4(), - endpoint, - keys, - userAgent: userAgent ?? req.headers['user-agent'] ?? '', - createdAt: Date.now(), - pushPreferences: pushPreferences ?? {}, - }); - return { success: true, data: { id: record.id } }; - }); - - this.app.put('/api/push/subscribe/:id', async (req) => { - const { id } = req.params as { id: string }; - const result = PushPreferencesUpdateSchema.safeParse(req.body); - if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); - } - const updated = this.pushStore.updatePreferences(id, result.data.pushPreferences); - if (!updated) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found'); - } - return { success: true }; - }); - - this.app.delete('/api/push/subscribe/:id', async (req) => { - const { id } = req.params as { id: string }; - const removed = this.pushStore.removeSubscription(id); - if (!removed) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found'); - } - return { success: true }; - }); - - // Screenshot upload endpoint (accepts multipart/form-data) - // Upload form served as static file: /upload.html (src/web/public/upload.html) - this.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 = { - 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 }; - }); - - // List screenshots - this.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 }; - }); - - // Serve individual screenshot - this.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 = { - 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); - }); + reply.code(statusCode).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(error))); + } + }); + + // Register all route modules + const ctx = this.createRouteContext(); + registerPushRoutes(this.app, ctx); + registerTeamRoutes(this.app, ctx); + registerMuxRoutes(this.app, ctx); + registerFileRoutes(this.app, ctx); + registerScheduledRoutes(this.app, ctx); + registerHookEventRoutes(this.app, ctx); + registerSystemRoutes(this.app, ctx); + registerCaseRoutes(this.app, ctx); + registerSessionRoutes(this.app, ctx); + registerRespawnRoutes(this.app, ctx); + registerRalphRoutes(this.app, ctx); + registerPlanRoutes(this.app, ctx); } /** @@ -4706,53 +712,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; this.mux.updateRespawnConfig(sessionId, persistedConfig); } - // Get system CPU and memory usage - private 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 cpuCount = WebServer.CPU_COUNT; - const cpuPercent = Math.min(100, Math.round((load / cpuCount) * 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 }, - }; - } - } - // Clean up all resources associated with a session // Track sessions currently being cleaned up to prevent concurrent cleanup races private cleaningUp: Set = new Set();