mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
refactor: extract server.ts routes into domain modules (phase 3)
Split 6,710-line server.ts into focused route modules using port interfaces for dependency injection. 107/109 routes extracted into 12 domain files with auth middleware, 5 port interfaces, and shared helpers. Server.ts retains orchestration (SSE, lifecycle, state). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<NiceConfig | undefined>;
|
||||
|
||||
/** 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<ScheduledRun>;
|
||||
|
||||
/** Stop a scheduled run by ID */
|
||||
stopScheduledRun(id: string): Promise<void>;
|
||||
|
||||
// === Notifications (used by hook-event route) ===
|
||||
|
||||
/** Send push notifications to all subscribed clients */
|
||||
sendPushNotifications(event: string, data: Record<string, unknown>): 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
|
||||
|
||||
|
||||
@@ -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<string, string> | null;
|
||||
authFailures: StaleExpirationMap<string, number> | 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<string, string>({
|
||||
ttlMs: AUTH_SESSION_TTL_MS,
|
||||
refreshOnGet: true,
|
||||
});
|
||||
|
||||
// Failure counter per IP — decay naturally after 15 minutes
|
||||
state.authFailures = new StaleExpirationMap<string, number>({
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -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<NiceConfig | undefined>;
|
||||
getModelConfig(): Promise<{ defaultModel?: string; agentTypeOverrides?: Record<string, string> } | null>;
|
||||
getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }>;
|
||||
getDefaultClaudeMdPath(): Promise<string | undefined>;
|
||||
getLightState(): unknown;
|
||||
startTranscriptWatcher(sessionId: string, transcriptPath: string): void;
|
||||
stopTranscriptWatcher(sessionId: string): void;
|
||||
}
|
||||
@@ -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<string, unknown>): void;
|
||||
batchTerminalData(sessionId: string, data: string): void;
|
||||
broadcastSessionStateDebounced(sessionId: string): void;
|
||||
batchTaskUpdate(sessionId: string, task: BackgroundTask): void;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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<string, RunSummaryTracker>;
|
||||
readonly activePlanOrchestrators: Map<string, PlanOrchestrator>;
|
||||
readonly scheduledRuns: Map<string, ScheduledRun>;
|
||||
readonly teamWatcher: TeamWatcher;
|
||||
readonly tunnelManager: TunnelManager;
|
||||
readonly pushStore: PushSubscriptionStore;
|
||||
startScheduledRun(prompt: string, workingDir: string, durationMinutes: number): Promise<ScheduledRun>;
|
||||
stopScheduledRun(id: string): Promise<void>;
|
||||
}
|
||||
@@ -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<string, RespawnController>;
|
||||
readonly respawnTimers: Map<string, { timer: NodeJS.Timeout; endAt: number; startedAt: number }>;
|
||||
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;
|
||||
}
|
||||
@@ -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<string, Session>;
|
||||
cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise<void>;
|
||||
setupSessionListeners(session: Session): Promise<void>;
|
||||
persistSessionState(session: Session): void;
|
||||
persistSessionStateNow(session: Session): void;
|
||||
getSessionStateWithRespawn(session: Session): unknown;
|
||||
}
|
||||
@@ -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<string, unknown> | null | undefined): Record<string, unknown> {
|
||||
if (!data || typeof data !== 'object') return {};
|
||||
|
||||
// Only forward known safe fields from Claude Code hook stdin
|
||||
const safeFields: Record<string, unknown> = {};
|
||||
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<string, unknown>;
|
||||
const summary: Record<string, unknown> = {};
|
||||
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 <promise> 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<CaseInfo[]> => {
|
||||
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<string, string> = 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<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||
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<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||
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<string, string> = {};
|
||||
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<string, string> = 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<string, string> = 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<string, string> = 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<string, string> = 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,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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<FileTreeNode[]> => {
|
||||
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<string, string> = {
|
||||
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 };
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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<ApiResponse> => {
|
||||
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<string, unknown>;
|
||||
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<ApiResponse> => {
|
||||
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<ApiResponse> => {
|
||||
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 };
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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<ApiResponse> => {
|
||||
// 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 \`<promise>${completionPhrase}</promise>\` 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 `<promise>BLOCKED</promise>` 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<string, Session>).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 <promise>${completionPhrase}</promise> 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<string, unknown> = {};
|
||||
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 },
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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<RespawnConfig> | 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<RespawnConfig>;
|
||||
}
|
||||
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<RespawnConfig>;
|
||||
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<RespawnConfig>;
|
||||
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<RespawnConfig>; 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(),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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<never>> => {
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -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<string, Session>).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<ApiResponse> => {
|
||||
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<ApiResponse<{ killed: number }>> => {
|
||||
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<ApiResponse> => {
|
||||
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<ApiResponse> => {
|
||||
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<ApiResponse> => {
|
||||
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<ApiResponse> => {
|
||||
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<ApiResponse> => {
|
||||
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=<bytes> - 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<string, Session>).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<QuickStartResponse> => {
|
||||
// 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<string, Session>).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<string, unknown> = {};
|
||||
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));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
|
||||
for (const [sessionId, session] of ctx.sessions) {
|
||||
activeSessionTokens[sessionId] = {
|
||||
inputTokens: session.inputTokens,
|
||||
outputTokens: session.outputTokens,
|
||||
totalCost: session.totalCost,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
stats: ctx.store.getAggregateStats(activeSessionTokens),
|
||||
raw: ctx.store.getGlobalStats(),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/token-stats', async () => {
|
||||
const activeSessionTokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
|
||||
for (const [sessionId, session] of ctx.sessions) {
|
||||
activeSessionTokens[sessionId] = {
|
||||
inputTokens: session.inputTokens,
|
||||
outputTokens: session.outputTokens,
|
||||
totalCost: session.totalCost,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
daily: ctx.store.getDailyStats(30),
|
||||
totals: ctx.store.getAggregateStats(activeSessionTokens),
|
||||
};
|
||||
});
|
||||
|
||||
// ========== Config ==========
|
||||
|
||||
app.get('/api/config', async () => {
|
||||
return { success: true, config: ctx.store.getConfig() };
|
||||
});
|
||||
|
||||
app.put('/api/config', async (req) => {
|
||||
const parseResult = ConfigUpdateSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid config: ${parseResult.error.message}`);
|
||||
}
|
||||
ctx.store.setConfig(parseResult.data as Partial<ReturnType<typeof ctx.store.getConfig>>);
|
||||
return { success: true, config: ctx.store.getConfig() };
|
||||
});
|
||||
|
||||
// ========== Debug/Memory ==========
|
||||
|
||||
app.get('/api/debug/memory', async () => {
|
||||
const mem = process.memoryUsage();
|
||||
const subagentStats = subagentWatcher.getStats();
|
||||
|
||||
const serverMapSizes = {
|
||||
sessions: ctx.sessions.size,
|
||||
runSummaryTrackers: ctx.runSummaryTrackers.size,
|
||||
scheduledRuns: ctx.scheduledRuns.size,
|
||||
activePlanOrchestrators: ctx.activePlanOrchestrators.size,
|
||||
};
|
||||
|
||||
const totalServerMapEntries = Object.values(serverMapSizes).reduce((a, b) => a + b, 0);
|
||||
const totalSubagentMapEntries = Object.values(subagentStats).reduce((a, b) => a + b, 0);
|
||||
|
||||
return {
|
||||
memory: {
|
||||
rss: mem.rss,
|
||||
rssMB: Math.round((mem.rss / 1024 / 1024) * 10) / 10,
|
||||
heapUsed: mem.heapUsed,
|
||||
heapUsedMB: Math.round((mem.heapUsed / 1024 / 1024) * 10) / 10,
|
||||
heapTotal: mem.heapTotal,
|
||||
heapTotalMB: Math.round((mem.heapTotal / 1024 / 1024) * 10) / 10,
|
||||
external: mem.external,
|
||||
externalMB: Math.round((mem.external / 1024 / 1024) * 10) / 10,
|
||||
arrayBuffers: mem.arrayBuffers,
|
||||
arrayBuffersMB: Math.round((mem.arrayBuffers / 1024 / 1024) * 10) / 10,
|
||||
},
|
||||
mapSizes: {
|
||||
server: serverMapSizes,
|
||||
subagentWatcher: subagentStats,
|
||||
totals: {
|
||||
serverEntries: totalServerMapEntries,
|
||||
subagentEntries: totalSubagentMapEntries,
|
||||
allEntries: totalServerMapEntries + totalSubagentMapEntries,
|
||||
},
|
||||
},
|
||||
watchers: {
|
||||
fileDebouncers: subagentStats.fileDebouncerCount,
|
||||
dirWatchers: subagentStats.dirWatcherCount,
|
||||
total: subagentStats.fileDebouncerCount + subagentStats.dirWatcherCount,
|
||||
},
|
||||
timers: {
|
||||
subagentIdleTimers: subagentStats.idleTimerCount,
|
||||
total: subagentStats.idleTimerCount,
|
||||
},
|
||||
uptime: {
|
||||
seconds: Math.round(process.uptime()),
|
||||
formatted: formatUptime(process.uptime()),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
});
|
||||
|
||||
// ========== System Stats ==========
|
||||
|
||||
app.get('/api/system/stats', async () => {
|
||||
return getSystemStats();
|
||||
});
|
||||
|
||||
// ========== Settings ==========
|
||||
|
||||
app.get('/api/settings', async () => {
|
||||
try {
|
||||
const content = await fs.readFile(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<string, unknown>;
|
||||
|
||||
try {
|
||||
const dir = dirname(settingsPath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
let existing: Record<string, unknown> = {};
|
||||
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<string, unknown>;
|
||||
|
||||
try {
|
||||
let existingSettings: Record<string, unknown> = {};
|
||||
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<NiceConfig>;
|
||||
|
||||
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<string, unknown>;
|
||||
try {
|
||||
const dir = dirname(windowStatesPath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
await fs.writeFile(windowStatesPath, JSON.stringify(states, null, 2));
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Subagent Parent Associations ==========
|
||||
|
||||
app.get('/api/subagent-parents', async () => {
|
||||
try {
|
||||
const content = await fs.readFile(parentMapPath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.error('Failed to read subagent parent map:', err);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
app.put('/api/subagent-parents', async (req) => {
|
||||
const spResult = SubagentParentMapSchema.safeParse(req.body);
|
||||
if (!spResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid parent map');
|
||||
}
|
||||
const parentMap = spResult.data;
|
||||
try {
|
||||
const dir = dirname(parentMapPath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
await fs.writeFile(parentMapPath, JSON.stringify(parentMap, null, 2));
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Subagent Monitoring ==========
|
||||
|
||||
app.get('/api/subagents', async (req) => {
|
||||
const { minutes } = req.query as { minutes?: string };
|
||||
const subagents = minutes
|
||||
? subagentWatcher.getRecentSubagents(parseInt(minutes, 10))
|
||||
: subagentWatcher.getSubagents();
|
||||
return { success: true, data: subagents };
|
||||
});
|
||||
|
||||
app.get('/api/sessions/:id/subagents', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const subagents = subagentWatcher.getSubagentsForSession(session.workingDir);
|
||||
return { success: true, data: subagents };
|
||||
});
|
||||
|
||||
app.get('/api/subagents/:agentId', async (req) => {
|
||||
const { agentId } = req.params as { agentId: string };
|
||||
const info = subagentWatcher.getSubagent(agentId);
|
||||
if (!info) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Subagent ${agentId} not found`);
|
||||
}
|
||||
return { success: true, data: info };
|
||||
});
|
||||
|
||||
app.get('/api/subagents/:agentId/transcript', async (req) => {
|
||||
const { agentId } = req.params as { agentId: string };
|
||||
const { limit, format } = req.query as { limit?: string; format?: 'raw' | 'formatted' };
|
||||
const limitNum = limit ? parseInt(limit, 10) : undefined;
|
||||
const transcript = await subagentWatcher.getTranscript(agentId, limitNum);
|
||||
|
||||
if (format === 'formatted') {
|
||||
const formatted = subagentWatcher.formatTranscript(transcript);
|
||||
return { success: true, data: { formatted, entryCount: transcript.length } };
|
||||
}
|
||||
|
||||
return { success: true, data: transcript };
|
||||
});
|
||||
|
||||
app.delete('/api/subagents/:agentId', async (req) => {
|
||||
const { agentId } = req.params as { agentId: string };
|
||||
const info = subagentWatcher.getSubagent(agentId);
|
||||
if (!info) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subagent not found');
|
||||
}
|
||||
|
||||
const killed = await subagentWatcher.killSubagent(agentId);
|
||||
if (killed) {
|
||||
return { success: true, data: { agentId, status: 'killed' } };
|
||||
}
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Subagent not found or already completed');
|
||||
});
|
||||
|
||||
app.post('/api/subagents/cleanup', async () => {
|
||||
const removed = subagentWatcher.cleanupNow();
|
||||
return { success: true, data: { removed, remaining: subagentWatcher.getSubagents().length } };
|
||||
});
|
||||
|
||||
app.delete('/api/subagents', async () => {
|
||||
const cleared = subagentWatcher.clearAll();
|
||||
return { success: true, data: { cleared } };
|
||||
});
|
||||
|
||||
// ========== Screenshots ==========
|
||||
|
||||
app.post('/api/screenshots', async (req, reply) => {
|
||||
const contentType = req.headers['content-type'] ?? '';
|
||||
if (!contentType.includes('multipart/form-data')) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
|
||||
}
|
||||
|
||||
// Parse multipart boundary
|
||||
const boundaryMatch = contentType.match(/boundary=(.+?)(?:;|$)/);
|
||||
if (!boundaryMatch) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing boundary');
|
||||
}
|
||||
|
||||
// Collect raw body
|
||||
const chunks: Buffer[] = [];
|
||||
let totalSize = 0;
|
||||
for await (const chunk of req.raw) {
|
||||
totalSize += chunk.length;
|
||||
if (totalSize > MAX_SCREENSHOT_SIZE) {
|
||||
reply.status(413);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'File too large (max 10MB)');
|
||||
}
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
const body = Buffer.concat(chunks);
|
||||
|
||||
// Extract file from multipart body
|
||||
const boundary = '--' + boundaryMatch[1];
|
||||
const boundaryBuf = Buffer.from(boundary);
|
||||
const parts: { headers: string; data: Buffer }[] = [];
|
||||
let pos = 0;
|
||||
|
||||
// Find each part between boundaries
|
||||
while (pos < body.length) {
|
||||
const start = body.indexOf(boundaryBuf, pos);
|
||||
if (start === -1) break;
|
||||
const afterBoundary = start + boundaryBuf.length;
|
||||
// Check for closing boundary (--)
|
||||
if (body[afterBoundary] === 0x2d && body[afterBoundary + 1] === 0x2d) break;
|
||||
// Skip \r\n after boundary
|
||||
const headerStart = afterBoundary + 2;
|
||||
const headerEnd = body.indexOf(Buffer.from('\r\n\r\n'), headerStart);
|
||||
if (headerEnd === -1) break;
|
||||
const headers = body.subarray(headerStart, headerEnd).toString();
|
||||
const dataStart = headerEnd + 4;
|
||||
const nextBoundary = body.indexOf(boundaryBuf, dataStart);
|
||||
// Data ends 2 bytes before next boundary (\r\n)
|
||||
const dataEnd = nextBoundary === -1 ? body.length : nextBoundary - 2;
|
||||
parts.push({ headers, data: body.subarray(dataStart, dataEnd) });
|
||||
pos = nextBoundary === -1 ? body.length : nextBoundary;
|
||||
}
|
||||
|
||||
const filePart = parts.find((p) => p.headers.includes('name="file"'));
|
||||
if (!filePart || filePart.data.length === 0) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No file uploaded');
|
||||
}
|
||||
|
||||
// Determine extension from Content-Type or filename
|
||||
let ext = '.png';
|
||||
const filenameMatch = filePart.headers.match(/filename="(.+?)"/);
|
||||
if (filenameMatch) {
|
||||
const origExt = filenameMatch[1].match(/\.(png|jpg|jpeg|webp|gif)$/i);
|
||||
if (origExt) ext = origExt[0].toLowerCase();
|
||||
}
|
||||
const ctMatch = filePart.headers.match(/Content-Type:\s*image\/(png|jpeg|webp|gif)/i);
|
||||
if (ctMatch) {
|
||||
const map: Record<string, string> = {
|
||||
png: '.png',
|
||||
jpeg: '.jpg',
|
||||
webp: '.webp',
|
||||
gif: '.gif',
|
||||
};
|
||||
ext = map[ctMatch[1].toLowerCase()] ?? ext;
|
||||
}
|
||||
|
||||
// Save to ~/.codeman/screenshots/
|
||||
if (!existsSync(SCREENSHOTS_DIR)) {
|
||||
mkdirSync(SCREENSHOTS_DIR, { recursive: true });
|
||||
}
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
|
||||
const filename = `screenshot_${timestamp}${ext}`;
|
||||
const filepath = join(SCREENSHOTS_DIR, filename);
|
||||
await fs.writeFile(filepath, filePart.data);
|
||||
|
||||
return { success: true, path: filepath, filename };
|
||||
});
|
||||
|
||||
app.get('/api/screenshots', async () => {
|
||||
if (!existsSync(SCREENSHOTS_DIR)) {
|
||||
return { files: [] };
|
||||
}
|
||||
const files = readdirSync(SCREENSHOTS_DIR)
|
||||
.filter((f) => /\.(png|jpg|jpeg|webp|gif)$/i.test(f))
|
||||
.sort()
|
||||
.reverse()
|
||||
.slice(0, 50)
|
||||
.map((name) => ({ name, path: join(SCREENSHOTS_DIR, name) }));
|
||||
return { files };
|
||||
});
|
||||
|
||||
app.get('/api/screenshots/:name', async (req, reply) => {
|
||||
const { name } = req.params as { name: string };
|
||||
// Prevent path traversal
|
||||
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
|
||||
reply.status(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid filename');
|
||||
}
|
||||
const filepath = join(SCREENSHOTS_DIR, name);
|
||||
if (!existsSync(filepath)) {
|
||||
reply.status(404);
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Screenshot not found');
|
||||
}
|
||||
const ext = name.match(/\.(png|jpg|jpeg|webp|gif)$/i)?.[1]?.toLowerCase() ?? 'png';
|
||||
const mimeMap: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
};
|
||||
reply.type(mimeMap[ext] ?? 'image/png');
|
||||
return fs.readFile(filepath);
|
||||
});
|
||||
}
|
||||
@@ -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) };
|
||||
});
|
||||
}
|
||||
+107
-4148
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user