mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
6643 lines
254 KiB
TypeScript
6643 lines
254 KiB
TypeScript
/**
|
||
* @fileoverview Codeman web server and REST API
|
||
*
|
||
* Provides a Fastify-based web server with:
|
||
* - REST API for session management, respawn control, and monitoring
|
||
* - Server-Sent Events (SSE) for real-time updates at /api/events
|
||
* - Static file serving for the web UI
|
||
* - 60fps terminal streaming with batched updates
|
||
*
|
||
* @module web/server
|
||
*/
|
||
|
||
import Fastify, { FastifyInstance, FastifyReply } from 'fastify';
|
||
import fastifyCompress from '@fastify/compress';
|
||
import fastifyCookie from '@fastify/cookie';
|
||
import fastifyStatic from '@fastify/static';
|
||
import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { existsSync, statSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, chmodSync } from 'node:fs';
|
||
import fs from 'node:fs/promises';
|
||
import { execSync } from 'node:child_process';
|
||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||
import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os';
|
||
import { EventEmitter } from 'node:events';
|
||
import {
|
||
Session,
|
||
ClaudeMessage,
|
||
type BackgroundTask,
|
||
type RalphTrackerState,
|
||
type RalphTodoItem,
|
||
type ActiveBashTool,
|
||
} from '../session.js';
|
||
import type { ClaudeMode } from '../types.js';
|
||
import { fileStreamManager } from '../file-stream-manager.js';
|
||
import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js';
|
||
import type { TerminalMultiplexer } from '../mux-interface.js';
|
||
import { createMultiplexer } from '../mux-factory.js';
|
||
import { getStore } from '../state-store.js';
|
||
import { generateClaudeMd } from '../templates/claude-md.js';
|
||
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
|
||
import { writeHooksConfig, updateCaseEnvVars } from '../hooks-config.js';
|
||
import {
|
||
subagentWatcher,
|
||
type SubagentInfo,
|
||
type SubagentToolCall,
|
||
type SubagentProgress,
|
||
type SubagentMessage,
|
||
type SubagentToolResult,
|
||
} from '../subagent-watcher.js';
|
||
import { imageWatcher } from '../image-watcher.js';
|
||
import { TranscriptWatcher } from '../transcript-watcher.js';
|
||
import { TeamWatcher } from '../team-watcher.js';
|
||
import { TunnelManager } from '../tunnel-manager.js';
|
||
import { v4 as uuidv4 } from 'uuid';
|
||
import { createRequire } from 'node:module';
|
||
import { RunSummaryTracker } from '../run-summary.js';
|
||
import { PlanOrchestrator, type DetailedPlanResult } from '../plan-orchestrator.js';
|
||
import { getLifecycleLog } from '../session-lifecycle-log.js';
|
||
import { PushSubscriptionStore } from '../push-store.js';
|
||
import webpush from 'web-push';
|
||
|
||
// Load version from package.json
|
||
const require = createRequire(import.meta.url);
|
||
const { version: APP_VERSION } = require('../../package.json');
|
||
import {
|
||
getErrorMessage,
|
||
ApiErrorCode,
|
||
createErrorResponse,
|
||
type ApiResponse,
|
||
type QuickStartResponse,
|
||
type CaseInfo,
|
||
type PersistedRespawnConfig,
|
||
type NiceConfig,
|
||
type ImageDetectedEvent,
|
||
DEFAULT_NICE_CONFIG,
|
||
} from '../types.js';
|
||
import {
|
||
CreateSessionSchema,
|
||
RunPromptSchema,
|
||
SessionInputWithLimitSchema,
|
||
ResizeSchema,
|
||
CreateCaseSchema,
|
||
QuickStartSchema,
|
||
HookEventSchema,
|
||
ConfigUpdateSchema,
|
||
RespawnConfigSchema,
|
||
SessionNameSchema,
|
||
SessionColorSchema,
|
||
RalphConfigSchema,
|
||
FixPlanImportSchema,
|
||
RalphPromptWriteSchema,
|
||
AutoClearSchema,
|
||
AutoCompactSchema,
|
||
ImageWatcherSchema,
|
||
FlickerFilterSchema,
|
||
QuickRunSchema,
|
||
ScheduledRunSchema,
|
||
LinkCaseSchema,
|
||
GeneratePlanSchema,
|
||
GeneratePlanDetailedSchema,
|
||
CancelPlanSchema,
|
||
PlanTaskUpdateSchema,
|
||
PlanTaskAddSchema,
|
||
CpuLimitSchema,
|
||
SettingsUpdateSchema,
|
||
ModelConfigUpdateSchema,
|
||
SubagentWindowStatesSchema,
|
||
SubagentParentMapSchema,
|
||
InteractiveRespawnSchema,
|
||
RespawnEnableSchema,
|
||
PushSubscribeSchema,
|
||
PushPreferencesUpdateSchema,
|
||
RalphLoopStartSchema,
|
||
isValidWorkingDir,
|
||
} from './schemas.js';
|
||
import { StaleExpirationMap } from '../utils/index.js';
|
||
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
|
||
interface ScheduledRun {
|
||
id: string;
|
||
prompt: string;
|
||
workingDir: string;
|
||
durationMinutes: number;
|
||
startedAt: number;
|
||
endAt: number;
|
||
status: 'running' | 'completed' | 'failed' | 'stopped';
|
||
sessionId: string | null;
|
||
completedTasks: number;
|
||
totalCost: number;
|
||
logs: string[];
|
||
}
|
||
|
||
// Batch terminal data for performance - collect for 16ms (60fps) before sending
|
||
const TERMINAL_BATCH_INTERVAL = 16;
|
||
// Batch task:updated events for 100ms
|
||
const TASK_UPDATE_BATCH_INTERVAL = 100;
|
||
|
||
// DEC mode 2026 - Synchronized Output
|
||
// When terminal supports this, it buffers all output between start/end markers
|
||
// and renders atomically, eliminating partial-frame flicker from Ink redraws.
|
||
// Supported by: WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal
|
||
const DEC_SYNC_START = '\x1b[?2026h'; // Begin synchronized update
|
||
const DEC_SYNC_END = '\x1b[?2026l'; // End synchronized update (flush to screen)
|
||
// State update debounce interval (batch expensive toDetailedState() calls)
|
||
const STATE_UPDATE_DEBOUNCE_INTERVAL = 500;
|
||
// Cache TTL for getLightSessionsState() — avoids re-serializing all sessions on every SSE init / /api/sessions call
|
||
const SESSIONS_LIST_CACHE_TTL = 1000;
|
||
// Scheduled runs cleanup interval (check every 5 minutes)
|
||
const SCHEDULED_CLEANUP_INTERVAL = 5 * 60 * 1000;
|
||
// Completed scheduled runs max age (1 hour)
|
||
const SCHEDULED_RUN_MAX_AGE = 60 * 60 * 1000;
|
||
// SSE client health check interval (every 30 seconds)
|
||
const SSE_HEALTH_CHECK_INTERVAL = 30 * 1000;
|
||
// Maximum allowed input length for session write (64KB)
|
||
const MAX_INPUT_LENGTH = 64 * 1024;
|
||
// Maximum terminal resize dimensions
|
||
const MAX_TERMINAL_COLS = 500;
|
||
const MAX_TERMINAL_ROWS = 200;
|
||
// Maximum session name length
|
||
const MAX_SESSION_NAME_LENGTH = 128;
|
||
// Maximum hook data size (prevents oversized SSE broadcasts)
|
||
const MAX_HOOK_DATA_SIZE = 8 * 1024;
|
||
// Maximum screenshot upload size (10MB)
|
||
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
|
||
// Auth session cookie TTL (24h — matches autonomous run length)
|
||
const AUTH_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
||
// Auth session cookie name
|
||
const AUTH_COOKIE_NAME = 'codeman_session';
|
||
// Max concurrent auth sessions
|
||
const MAX_AUTH_SESSIONS = 100;
|
||
// Max failed auth attempts per IP before rate-limiting
|
||
const AUTH_FAILURE_MAX = 10;
|
||
// Failed auth attempt tracking window (15 minutes)
|
||
const AUTH_FAILURE_WINDOW_MS = 15 * 60 * 1000;
|
||
// Screenshots directory
|
||
const SCREENSHOTS_DIR = join(homedir(), '.codeman', 'screenshots');
|
||
// Stats collection interval (2 seconds)
|
||
const STATS_COLLECTION_INTERVAL_MS = 2000;
|
||
// Session limit wait time before retrying (5 seconds)
|
||
const SESSION_LIMIT_WAIT_MS = 5000;
|
||
// Pause between scheduled run iterations (2 seconds)
|
||
const ITERATION_PAUSE_MS = 2000;
|
||
// Terminal batch flush threshold - flush immediately if batch exceeds this size
|
||
// Set high (32KB) to allow effective batching; avg Ink events are ~14KB
|
||
const BATCH_FLUSH_THRESHOLD = 32 * 1024;
|
||
// Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation)
|
||
// eslint-disable-next-line no-control-regex
|
||
const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/;
|
||
// eslint-disable-next-line no-control-regex
|
||
const CTRL_L_PATTERN = /\x0c/g;
|
||
const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
|
||
|
||
/**
|
||
* Formats uptime in seconds to a human-readable string.
|
||
*/
|
||
function formatUptime(seconds: number): string {
|
||
const days = Math.floor(seconds / 86400);
|
||
const hours = Math.floor((seconds % 86400) / 3600);
|
||
const minutes = Math.floor((seconds % 3600) / 60);
|
||
const secs = Math.floor(seconds % 60);
|
||
|
||
const parts: string[] = [];
|
||
if (days > 0) parts.push(`${days}d`);
|
||
if (hours > 0) parts.push(`${hours}h`);
|
||
if (minutes > 0) parts.push(`${minutes}m`);
|
||
if (secs > 0 || parts.length === 0) parts.push(`${secs}s`);
|
||
|
||
return parts.join(' ');
|
||
}
|
||
|
||
/**
|
||
* Sanitizes hook event data before broadcasting via SSE.
|
||
* Extracts only relevant fields and limits total size to prevent
|
||
* oversized payloads from being broadcast to all connected clients.
|
||
*/
|
||
function sanitizeHookData(data: Record<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.
|
||
*/
|
||
function autoConfigureRalph(
|
||
session: Session,
|
||
workingDir: string,
|
||
broadcast: (event: string, data: unknown) => void
|
||
): void {
|
||
// First, try to read the official Ralph Wiggum plugin state file
|
||
const ralphConfig = parseRalphLoopConfig(workingDir);
|
||
|
||
if (ralphConfig && ralphConfig.completionPromise) {
|
||
session.ralphTracker.enable();
|
||
session.ralphTracker.startLoop(ralphConfig.completionPromise, ralphConfig.maxIterations ?? undefined);
|
||
|
||
// Restore iteration count if available
|
||
if (ralphConfig.iteration > 0) {
|
||
// The tracker's cycleCount will be updated when we detect iteration patterns
|
||
// in the terminal output, but we can set maxIterations now
|
||
console.log(`[auto-detect] Ralph loop at iteration ${ralphConfig.iteration}/${ralphConfig.maxIterations ?? '∞'}`);
|
||
}
|
||
|
||
console.log(
|
||
`[auto-detect] Configured Ralph loop for session ${session.id} from ralph-loop.local.md: ${ralphConfig.completionPromise}`
|
||
);
|
||
broadcast('session:ralphLoopUpdate', {
|
||
sessionId: session.id,
|
||
state: session.ralphTracker.loopState,
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Fallback: try CLAUDE.md
|
||
const claudeMdPath = join(workingDir, 'CLAUDE.md');
|
||
const completionPhrase = extractCompletionPhrase(claudeMdPath);
|
||
|
||
if (completionPhrase) {
|
||
session.ralphTracker.enable();
|
||
session.ralphTracker.startLoop(completionPhrase);
|
||
console.log(`[auto-detect] Configured Ralph loop for session ${session.id} from CLAUDE.md: ${completionPhrase}`);
|
||
broadcast('session:ralphLoopUpdate', {
|
||
sessionId: session.id,
|
||
state: session.ralphTracker.loopState,
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get or generate a self-signed TLS certificate for HTTPS.
|
||
* Certs are stored in ~/.codeman/certs/ and reused across restarts.
|
||
*/
|
||
function getOrCreateSelfSignedCert(): { key: string; cert: string } {
|
||
const certsDir = join(homedir(), '.codeman', 'certs');
|
||
const keyPath = join(certsDir, 'server.key');
|
||
const certPath = join(certsDir, 'server.crt');
|
||
|
||
if (existsSync(keyPath) && existsSync(certPath)) {
|
||
return {
|
||
key: readFileSync(keyPath, 'utf-8'),
|
||
cert: readFileSync(certPath, 'utf-8'),
|
||
};
|
||
}
|
||
|
||
mkdirSync(certsDir, { recursive: true, mode: 0o700 });
|
||
|
||
// Generate self-signed cert valid for 365 days, covering localhost and common LAN access patterns
|
||
execSync(
|
||
`openssl req -x509 -newkey rsa:2048 -nodes ` +
|
||
`-keyout "${keyPath}" -out "${certPath}" ` +
|
||
`-days 365 -subj "/CN=codeman" ` +
|
||
`-addext "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:0.0.0.0"`,
|
||
{ stdio: 'pipe' }
|
||
);
|
||
|
||
// Restrict private key to owner-only (prevent other local users from reading it)
|
||
chmodSync(keyPath, 0o600);
|
||
|
||
return {
|
||
key: readFileSync(keyPath, 'utf-8'),
|
||
cert: readFileSync(certPath, 'utf-8'),
|
||
};
|
||
}
|
||
|
||
/** Stored listener references for session cleanup (prevents memory leaks) */
|
||
interface SessionListenerRefs {
|
||
terminal: (data: string) => void;
|
||
clearTerminal: () => void;
|
||
needsRefresh: () => void;
|
||
message: (msg: ClaudeMessage) => void;
|
||
error: (error: string) => void;
|
||
completion: (result: string, cost: number) => void;
|
||
exit: (code: number | null) => void;
|
||
working: () => void;
|
||
idle: () => void;
|
||
taskCreated: (task: BackgroundTask) => void;
|
||
taskUpdated: (task: BackgroundTask) => void;
|
||
taskCompleted: (task: BackgroundTask) => void;
|
||
taskFailed: (task: BackgroundTask, error: string) => void;
|
||
autoClear: (data: { tokens: number; threshold: number }) => void;
|
||
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void;
|
||
cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => void;
|
||
ralphLoopUpdate: (state: RalphTrackerState) => void;
|
||
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
|
||
ralphCompletionDetected: (phrase: string) => void;
|
||
ralphStatusBlockDetected: (block: import('../types.js').RalphStatusBlock) => void;
|
||
ralphCircuitBreakerUpdate: (status: import('../types.js').CircuitBreakerStatus) => void;
|
||
ralphExitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => void;
|
||
bashToolStart: (tool: ActiveBashTool) => void;
|
||
bashToolEnd: (tool: ActiveBashTool) => void;
|
||
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
|
||
}
|
||
|
||
export class WebServer extends EventEmitter {
|
||
/** Cached CPU count — doesn't change at runtime */
|
||
private static readonly CPU_COUNT = cpus().length;
|
||
|
||
private app: FastifyInstance;
|
||
private sessions: Map<string, Session> = new Map();
|
||
private respawnControllers: Map<string, RespawnController> = new Map();
|
||
private respawnTimers: Map<string, { timer: NodeJS.Timeout; endAt: number; startedAt: number }> = new Map();
|
||
private runSummaryTrackers: Map<string, RunSummaryTracker> = new Map();
|
||
private transcriptWatchers: Map<string, TranscriptWatcher> = new Map();
|
||
// Store session listener references for explicit cleanup (prevents memory leaks)
|
||
private sessionListenerRefs: Map<string, SessionListenerRefs> = new Map();
|
||
private scheduledRuns: Map<string, ScheduledRun> = new Map();
|
||
private sseClients: Set<FastifyReply> = new Set();
|
||
/** Clients with backpressure — skip writes until 'drain' fires */
|
||
private backpressuredClients: Set<FastifyReply> = new Set();
|
||
private store = getStore();
|
||
private port: number;
|
||
private https: boolean;
|
||
private testMode: boolean;
|
||
private mux: TerminalMultiplexer;
|
||
// Terminal batching for performance
|
||
private terminalBatches: Map<string, string[]> = new Map();
|
||
private terminalBatchSizes: Map<string, number> = new Map(); // Running total avoids O(n) reduce per push
|
||
private terminalBatchTimers: Map<string, NodeJS.Timeout> = new Map(); // Per-session timers (staggered flushes)
|
||
// Adaptive batching: track rapid events to extend batch window (per-session)
|
||
// StaleExpirationMap auto-cleans entries for sessions that stop generating output
|
||
private lastTerminalEventTime: StaleExpirationMap<string, number> = new StaleExpirationMap({
|
||
ttlMs: 5 * 60 * 1000, // 5 minutes - auto-expire stale session timing data
|
||
refreshOnGet: false, // Don't refresh on reads, only on explicit sets
|
||
});
|
||
// Scheduled runs cleanup timer
|
||
private scheduledCleanupTimer: NodeJS.Timeout | null = null;
|
||
// SSE event batching
|
||
private taskUpdateBatches: Map<string, { sessionId: string; task: BackgroundTask }> = new Map();
|
||
private taskUpdateBatchTimer: NodeJS.Timeout | null = null;
|
||
// State update batching (reduce expensive toDetailedState() serialization)
|
||
private stateUpdatePending: Set<string> = new Set();
|
||
private stateUpdateTimer: NodeJS.Timeout | null = null;
|
||
// SSE client health check timer
|
||
private sseHealthCheckTimer: NodeJS.Timeout | null = null;
|
||
// Flag to prevent new timers during shutdown
|
||
private _isStopping: boolean = false;
|
||
// Cached light state for SSE init (avoids rebuilding on every reconnect)
|
||
private cachedLightState: { data: Record<string, unknown>; timestamp: number } | null = null;
|
||
private static readonly LIGHT_STATE_CACHE_TTL_MS = 1000;
|
||
// Cached sessions list for getLightSessionsState() (avoids re-serializing all sessions on every call)
|
||
private cachedSessionsList: { data: unknown[]; timestamp: number } | null = null;
|
||
// Token recording for daily stats (track what's been recorded to avoid double-counting)
|
||
private lastRecordedTokens: Map<string, { input: number; output: number }> = new Map();
|
||
private tokenRecordingTimer: NodeJS.Timeout | null = null;
|
||
// Server startup time for respawn grace period calculation
|
||
private readonly serverStartTime: number = Date.now();
|
||
// Pending respawn start timers (for cleanup on shutdown)
|
||
private pendingRespawnStarts: Map<string, NodeJS.Timeout> = new Map();
|
||
// Active plan orchestrators (for cancellation via API)
|
||
private activePlanOrchestrators: Map<string, PlanOrchestrator> = new Map();
|
||
private persistDebounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
||
// Grace period before starting restored respawn controllers (2 minutes)
|
||
private static readonly RESPAWN_RESTORE_GRACE_PERIOD_MS = 2 * 60 * 1000;
|
||
// Stored listener handlers for cleanup
|
||
private subagentWatcherHandlers: {
|
||
discovered: (info: SubagentInfo) => void;
|
||
updated: (info: SubagentInfo) => void;
|
||
toolCall: (data: SubagentToolCall) => void;
|
||
toolResult: (data: SubagentToolResult) => void;
|
||
progress: (data: SubagentProgress) => void;
|
||
message: (data: SubagentMessage) => void;
|
||
completed: (info: SubagentInfo) => void;
|
||
error: (error: Error, agentId?: string) => void;
|
||
} | null = null;
|
||
private imageWatcherHandlers: {
|
||
detected: (event: ImageDetectedEvent) => void;
|
||
error: (error: Error, sessionId?: string) => void;
|
||
} | null = null;
|
||
private tunnelManager: TunnelManager = new TunnelManager();
|
||
private authSessions: StaleExpirationMap<string, string> | null = null;
|
||
private authFailures: StaleExpirationMap<string, number> | null = null;
|
||
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
|
||
private teamWatcher: TeamWatcher = new TeamWatcher();
|
||
private teamWatcherHandlers: {
|
||
teamCreated: (config: unknown) => void;
|
||
teamUpdated: (config: unknown) => void;
|
||
teamRemoved: (config: unknown) => void;
|
||
taskUpdated: (data: unknown) => void;
|
||
} | null = null;
|
||
constructor(port: number = 3000, https: boolean = false, testMode: boolean = false) {
|
||
super();
|
||
this.setMaxListeners(0);
|
||
this.port = port;
|
||
this.https = https;
|
||
this.testMode = testMode;
|
||
|
||
if (https) {
|
||
const { key, cert } = getOrCreateSelfSignedCert();
|
||
this.app = Fastify({ logger: false, https: { key, cert } });
|
||
} else {
|
||
this.app = Fastify({ logger: false });
|
||
}
|
||
this.mux = createMultiplexer();
|
||
|
||
// Set up mux event listeners
|
||
this.mux.on('sessionCreated', (session) => {
|
||
this.broadcast('mux:created', session);
|
||
});
|
||
this.mux.on('sessionKilled', (data) => {
|
||
this.broadcast('mux:killed', data);
|
||
});
|
||
this.mux.on('sessionDied', (data) => {
|
||
getLifecycleLog().log({
|
||
event: 'mux_died',
|
||
sessionId: (data as { sessionId?: string }).sessionId || 'unknown',
|
||
extra: data as Record<string, unknown>,
|
||
});
|
||
this.broadcast('mux:died', data);
|
||
});
|
||
this.mux.on('statsUpdated', (sessions) => {
|
||
this.broadcast('mux:statsUpdated', sessions);
|
||
});
|
||
|
||
// Set up subagent watcher listeners
|
||
this.setupSubagentWatcherListeners();
|
||
|
||
// Set up image watcher listeners
|
||
this.setupImageWatcherListeners();
|
||
|
||
// Set up team watcher listeners
|
||
this.setupTeamWatcherListeners();
|
||
|
||
// Set up tunnel manager listeners
|
||
this.tunnelManager.on('started', (data: { url: string }) => {
|
||
this.broadcast('tunnel:started', data);
|
||
});
|
||
this.tunnelManager.on('stopped', () => {
|
||
this.broadcast('tunnel:stopped', {});
|
||
});
|
||
this.tunnelManager.on('error', (message: string) => {
|
||
this.broadcast('tunnel:error', { message });
|
||
});
|
||
this.tunnelManager.on('progress', (data: { message: string }) => {
|
||
this.broadcast('tunnel:progress', data);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Set up event listeners for subagent watcher.
|
||
* Broadcasts real-time subagent activity to SSE clients.
|
||
*
|
||
* The SubagentWatcher now extracts descriptions directly from the parent session's
|
||
* transcript, which contains the exact Task tool call with the description parameter.
|
||
* This is more reliable than the previous timing-based correlation approach.
|
||
*/
|
||
private setupSubagentWatcherListeners(): void {
|
||
// Store handlers for cleanup on shutdown
|
||
this.subagentWatcherHandlers = {
|
||
discovered: (info: SubagentInfo) => this.broadcast('subagent:discovered', info),
|
||
updated: (info: SubagentInfo) => this.broadcast('subagent:updated', info),
|
||
toolCall: (data: SubagentToolCall) => this.broadcast('subagent:tool_call', data),
|
||
toolResult: (data: SubagentToolResult) => this.broadcast('subagent:tool_result', data),
|
||
progress: (data: SubagentProgress) => this.broadcast('subagent:progress', data),
|
||
message: (data: SubagentMessage) => this.broadcast('subagent:message', data),
|
||
completed: (info: SubagentInfo) => this.broadcast('subagent:completed', info),
|
||
error: (error: Error, agentId?: string) => {
|
||
console.error(`[SubagentWatcher] Error${agentId ? ` for ${agentId}` : ''}:`, error.message);
|
||
},
|
||
};
|
||
|
||
subagentWatcher.on('subagent:discovered', this.subagentWatcherHandlers.discovered);
|
||
subagentWatcher.on('subagent:updated', this.subagentWatcherHandlers.updated);
|
||
subagentWatcher.on('subagent:tool_call', this.subagentWatcherHandlers.toolCall);
|
||
subagentWatcher.on('subagent:tool_result', this.subagentWatcherHandlers.toolResult);
|
||
subagentWatcher.on('subagent:progress', this.subagentWatcherHandlers.progress);
|
||
subagentWatcher.on('subagent:message', this.subagentWatcherHandlers.message);
|
||
subagentWatcher.on('subagent:completed', this.subagentWatcherHandlers.completed);
|
||
subagentWatcher.on('subagent:error', this.subagentWatcherHandlers.error);
|
||
}
|
||
|
||
/**
|
||
* Clean up subagent watcher listeners to prevent memory leaks.
|
||
*/
|
||
private cleanupSubagentWatcherListeners(): void {
|
||
if (this.subagentWatcherHandlers) {
|
||
subagentWatcher.off('subagent:discovered', this.subagentWatcherHandlers.discovered);
|
||
subagentWatcher.off('subagent:updated', this.subagentWatcherHandlers.updated);
|
||
subagentWatcher.off('subagent:tool_call', this.subagentWatcherHandlers.toolCall);
|
||
subagentWatcher.off('subagent:tool_result', this.subagentWatcherHandlers.toolResult);
|
||
subagentWatcher.off('subagent:progress', this.subagentWatcherHandlers.progress);
|
||
subagentWatcher.off('subagent:message', this.subagentWatcherHandlers.message);
|
||
subagentWatcher.off('subagent:completed', this.subagentWatcherHandlers.completed);
|
||
subagentWatcher.off('subagent:error', this.subagentWatcherHandlers.error);
|
||
this.subagentWatcherHandlers = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Set up event listeners for image watcher.
|
||
* Broadcasts image detection events to SSE clients for auto-popup.
|
||
*/
|
||
private setupImageWatcherListeners(): void {
|
||
// Store handlers for cleanup on shutdown
|
||
this.imageWatcherHandlers = {
|
||
detected: (event: ImageDetectedEvent) => this.broadcast('image:detected', event),
|
||
error: (error: Error, sessionId?: string) => {
|
||
console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message);
|
||
},
|
||
};
|
||
|
||
imageWatcher.on('image:detected', this.imageWatcherHandlers.detected);
|
||
imageWatcher.on('image:error', this.imageWatcherHandlers.error);
|
||
}
|
||
|
||
/**
|
||
* Clean up image watcher listeners to prevent memory leaks.
|
||
*/
|
||
private cleanupImageWatcherListeners(): void {
|
||
if (this.imageWatcherHandlers) {
|
||
imageWatcher.off('image:detected', this.imageWatcherHandlers.detected);
|
||
imageWatcher.off('image:error', this.imageWatcherHandlers.error);
|
||
this.imageWatcherHandlers = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Set up event listeners for team watcher.
|
||
* Broadcasts team activity events to SSE clients.
|
||
*/
|
||
private setupTeamWatcherListeners(): void {
|
||
this.teamWatcherHandlers = {
|
||
teamCreated: (config: unknown) => this.broadcast('team:created', config),
|
||
teamUpdated: (config: unknown) => this.broadcast('team:updated', config),
|
||
teamRemoved: (config: unknown) => this.broadcast('team:removed', config),
|
||
taskUpdated: (data: unknown) => this.broadcast('team:taskUpdated', data),
|
||
};
|
||
|
||
this.teamWatcher.on('teamCreated', this.teamWatcherHandlers.teamCreated);
|
||
this.teamWatcher.on('teamUpdated', this.teamWatcherHandlers.teamUpdated);
|
||
this.teamWatcher.on('teamRemoved', this.teamWatcherHandlers.teamRemoved);
|
||
this.teamWatcher.on('taskUpdated', this.teamWatcherHandlers.taskUpdated);
|
||
}
|
||
|
||
/**
|
||
* Clean up team watcher listeners to prevent memory leaks.
|
||
*/
|
||
private cleanupTeamWatcherListeners(): void {
|
||
if (this.teamWatcherHandlers) {
|
||
this.teamWatcher.off('teamCreated', this.teamWatcherHandlers.teamCreated);
|
||
this.teamWatcher.off('teamUpdated', this.teamWatcherHandlers.teamUpdated);
|
||
this.teamWatcher.off('teamRemoved', this.teamWatcherHandlers.teamRemoved);
|
||
this.teamWatcher.off('taskUpdated', this.teamWatcherHandlers.taskUpdated);
|
||
this.teamWatcherHandlers = null;
|
||
}
|
||
}
|
||
|
||
private async setupRoutes(): Promise<void> {
|
||
// Allow multipart/form-data for screenshot uploads — skip Fastify's body parser
|
||
// so the route handler can read the raw stream directly.
|
||
this.app.addContentTypeParser('multipart/form-data', (_req, _payload, done) => {
|
||
done(null);
|
||
});
|
||
|
||
// Enable gzip/brotli compression for all responses.
|
||
// Massive win: 793KB uncompressed → ~120KB compressed for static assets.
|
||
// Threshold 1024 = don't compress tiny responses (headers > savings).
|
||
await this.app.register(fastifyCompress, {
|
||
threshold: 1024,
|
||
});
|
||
|
||
// Cookie plugin (needed for auth session tokens)
|
||
await this.app.register(fastifyCookie);
|
||
|
||
// Optional HTTP Basic Auth with session cookies and rate limiting
|
||
const authPassword = process.env.CODEMAN_PASSWORD;
|
||
if (authPassword) {
|
||
const authUsername = process.env.CODEMAN_USERNAME || 'admin';
|
||
const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64');
|
||
|
||
// Session token store — active sessions extend TTL on access
|
||
this.authSessions = new StaleExpirationMap<string, string>({
|
||
ttlMs: AUTH_SESSION_TTL_MS,
|
||
refreshOnGet: true,
|
||
});
|
||
|
||
// Failure counter per IP — decay naturally after 15 minutes
|
||
this.authFailures = new StaleExpirationMap<string, number>({
|
||
ttlMs: AUTH_FAILURE_WINDOW_MS,
|
||
refreshOnGet: false,
|
||
});
|
||
|
||
this.app.addHook('onRequest', (req, reply, done) => {
|
||
// Hook events come from local Claude Code hooks (curl from localhost) — no auth headers available.
|
||
// Safe: validated by HookEventSchema, only triggers broadcasts.
|
||
// Security: restrict bypass to localhost only — prevents forged hook events via tunnel/LAN.
|
||
if (req.url === '/api/hook-event' && req.method === 'POST') {
|
||
const ip = req.ip;
|
||
if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') {
|
||
done();
|
||
return;
|
||
}
|
||
// Non-localhost hook requests fall through to normal auth
|
||
}
|
||
|
||
const clientIp = req.ip;
|
||
|
||
// Rate limit: reject if too many failed attempts from this IP
|
||
const failures = this.authFailures!.get(clientIp) ?? 0;
|
||
if (failures >= AUTH_FAILURE_MAX) {
|
||
reply.code(429).send('Too Many Requests — try again later');
|
||
return;
|
||
}
|
||
|
||
// Check session cookie first (avoids re-sending credentials on every request)
|
||
// Use get() instead of has() so refreshOnGet extends the TTL on active sessions
|
||
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
|
||
if (sessionToken && this.authSessions!.get(sessionToken) !== undefined) {
|
||
done();
|
||
return;
|
||
}
|
||
|
||
// Check Basic Auth header (timing-safe comparison to prevent side-channel attacks)
|
||
const auth = req.headers.authorization;
|
||
const authBuf = Buffer.from(auth ?? '');
|
||
const expectedBuf = Buffer.from(expectedHeader);
|
||
if (authBuf.length === expectedBuf.length && timingSafeEqual(authBuf, expectedBuf)) {
|
||
// Issue session token cookie so browser doesn't need to re-send credentials
|
||
const token = randomBytes(32).toString('hex');
|
||
|
||
// Evict oldest if at capacity (prevent unbounded growth)
|
||
if (this.authSessions!.size >= MAX_AUTH_SESSIONS) {
|
||
const oldestKey = this.authSessions!.keys().next().value;
|
||
if (oldestKey !== undefined) this.authSessions!.delete(oldestKey);
|
||
}
|
||
|
||
this.authSessions!.set(token, clientIp);
|
||
|
||
// Reset failure count on successful auth
|
||
this.authFailures!.delete(clientIp);
|
||
|
||
reply.setCookie(AUTH_COOKIE_NAME, token, {
|
||
httpOnly: true,
|
||
secure: this.https,
|
||
sameSite: 'lax',
|
||
maxAge: AUTH_SESSION_TTL_MS / 1000, // seconds
|
||
path: '/',
|
||
});
|
||
done();
|
||
return;
|
||
}
|
||
|
||
// Auth failed — track failure count
|
||
this.authFailures!.set(clientIp, failures + 1);
|
||
|
||
reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
|
||
reply.code(401).send('Unauthorized');
|
||
});
|
||
}
|
||
|
||
// Security headers + CORS on every response
|
||
this.app.addHook('onRequest', (req, reply, done) => {
|
||
reply.header('X-Content-Type-Options', 'nosniff');
|
||
reply.header('X-Frame-Options', 'SAMEORIGIN');
|
||
reply.header(
|
||
'Content-Security-Policy',
|
||
"default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: blob:; connect-src 'self' wss://api.deepgram.com; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'"
|
||
);
|
||
if (this.https) {
|
||
reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||
}
|
||
|
||
// CORS: restrict to same-origin (localhost) only
|
||
const origin = req.headers.origin;
|
||
if (origin) {
|
||
try {
|
||
const url = new URL(origin);
|
||
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1') {
|
||
reply.header('Access-Control-Allow-Origin', origin);
|
||
reply.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||
reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||
reply.header('Access-Control-Max-Age', '86400');
|
||
}
|
||
} catch {
|
||
// Invalid origin header — do not set CORS headers
|
||
}
|
||
}
|
||
|
||
// Handle CORS preflight
|
||
if (req.method === 'OPTIONS') {
|
||
reply.code(204).send();
|
||
done();
|
||
return;
|
||
}
|
||
|
||
done();
|
||
});
|
||
|
||
// Service worker must never be cached — browsers check for SW updates on navigation
|
||
this.app.get('/sw.js', async (_req, reply) => {
|
||
return reply
|
||
.header('Cache-Control', 'no-cache, no-store')
|
||
.header('Service-Worker-Allowed', '/')
|
||
.type('application/javascript')
|
||
.sendFile('sw.js', join(__dirname, 'public'));
|
||
});
|
||
|
||
// Serve static files — versioned assets (?v=X) are immutable, cache aggressively
|
||
// preCompressed: serve pre-built .br/.gz files (from build step) to avoid per-request CPU compression
|
||
await this.app.register(fastifyStatic, {
|
||
root: join(__dirname, 'public'),
|
||
prefix: '/',
|
||
maxAge: '1y',
|
||
immutable: true,
|
||
preCompressed: true,
|
||
});
|
||
|
||
// SSE endpoint for real-time updates
|
||
this.app.get('/api/events', (req, reply) => {
|
||
// Enforce SSE client limit to prevent memory exhaustion from too many connections
|
||
if (this.sseClients.size >= MAX_SSE_CLIENTS) {
|
||
reply.code(503).send('Too many SSE connections');
|
||
return;
|
||
}
|
||
|
||
reply.raw.writeHead(200, {
|
||
'Content-Type': 'text/event-stream',
|
||
'Cache-Control': 'no-cache',
|
||
Connection: 'keep-alive',
|
||
'X-Accel-Buffering': 'no', // Disable nginx buffering
|
||
});
|
||
|
||
this.sseClients.add(reply);
|
||
|
||
// Send initial state
|
||
// Use light state for SSE init to avoid sending 2MB+ terminal buffers
|
||
// Buffers are fetched on-demand when switching tabs
|
||
this.sendSSE(reply, 'init', this.getLightState());
|
||
|
||
req.raw.on('close', () => {
|
||
this.sseClients.delete(reply);
|
||
this.backpressuredClients.delete(reply);
|
||
});
|
||
});
|
||
|
||
// API Routes
|
||
|
||
// Logout: invalidate session cookie
|
||
this.app.post('/api/logout', async (req, reply) => {
|
||
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
|
||
if (sessionToken && this.authSessions) {
|
||
this.authSessions.delete(sessionToken);
|
||
}
|
||
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
|
||
return { success: true };
|
||
});
|
||
|
||
this.app.get('/api/status', async () => this.getLightState());
|
||
|
||
this.app.get('/api/tunnel/status', async () => this.tunnelManager.getStatus());
|
||
|
||
this.app.get('/api/tunnel/qr', async (_req, reply) => {
|
||
const url = this.tunnelManager.getUrl();
|
||
if (!url) {
|
||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Tunnel not running'));
|
||
}
|
||
try {
|
||
const QRCode = require('qrcode');
|
||
const svg: string = await QRCode.toString(url, { type: 'svg', margin: 2, width: 256 });
|
||
// Return as data URI to avoid Fastify compress issues with SVG content-type
|
||
return { svg };
|
||
} catch (err) {
|
||
return reply.code(500).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)));
|
||
}
|
||
});
|
||
|
||
// OpenCode CLI availability check
|
||
this.app.get('/api/opencode/status', async () => {
|
||
const { isOpenCodeAvailable, resolveOpenCodeDir } = await import('../utils/opencode-cli-resolver.js');
|
||
return {
|
||
available: isOpenCodeAvailable(),
|
||
path: resolveOpenCodeDir(),
|
||
};
|
||
});
|
||
|
||
// Cleanup stale sessions from state file
|
||
this.app.post('/api/cleanup-state', async () => {
|
||
const cleaned = this.cleanupStaleSessions();
|
||
return { success: true, cleanedSessions: cleaned };
|
||
});
|
||
|
||
// Session lifecycle audit log
|
||
this.app.get('/api/session-lifecycle', async (req) => {
|
||
const query = req.query as {
|
||
sessionId?: string;
|
||
event?: string;
|
||
since?: string;
|
||
limit?: string;
|
||
};
|
||
const lifecycleLog = getLifecycleLog();
|
||
const entries = await lifecycleLog.query({
|
||
sessionId: query.sessionId,
|
||
event: query.event as import('../types.js').LifecycleEventType,
|
||
since: query.since ? Number(query.since) : undefined,
|
||
limit: query.limit ? Math.min(Number(query.limit), 1000) : 200,
|
||
});
|
||
return { success: true, entries };
|
||
});
|
||
|
||
// Global stats endpoint
|
||
this.app.get('/api/stats', async () => {
|
||
const activeSessionTokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> =
|
||
{};
|
||
for (const [sessionId, session] of this.sessions) {
|
||
activeSessionTokens[sessionId] = {
|
||
inputTokens: session.inputTokens,
|
||
outputTokens: session.outputTokens,
|
||
totalCost: session.totalCost,
|
||
};
|
||
}
|
||
return {
|
||
success: true,
|
||
stats: this.store.getAggregateStats(activeSessionTokens),
|
||
raw: this.store.getGlobalStats(),
|
||
};
|
||
});
|
||
|
||
// Token stats with daily history
|
||
this.app.get('/api/token-stats', async () => {
|
||
// Get aggregate totals (global + active sessions)
|
||
const activeSessionTokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> =
|
||
{};
|
||
for (const [sessionId, session] of this.sessions) {
|
||
activeSessionTokens[sessionId] = {
|
||
inputTokens: session.inputTokens,
|
||
outputTokens: session.outputTokens,
|
||
totalCost: session.totalCost,
|
||
};
|
||
}
|
||
return {
|
||
success: true,
|
||
daily: this.store.getDailyStats(30),
|
||
totals: this.store.getAggregateStats(activeSessionTokens),
|
||
};
|
||
});
|
||
|
||
this.app.get('/api/config', async () => {
|
||
return { success: true, config: this.store.getConfig() };
|
||
});
|
||
|
||
this.app.put('/api/config', async (req) => {
|
||
// Validate request body against schema to prevent arbitrary config injection
|
||
const parseResult = ConfigUpdateSchema.safeParse(req.body);
|
||
if (!parseResult.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid config: ${parseResult.error.message}`);
|
||
}
|
||
this.store.setConfig(parseResult.data as Partial<ReturnType<typeof this.store.getConfig>>);
|
||
return { success: true, config: this.store.getConfig() };
|
||
});
|
||
|
||
// Debug/monitoring endpoint - lightweight, only runs when called
|
||
// Returns comprehensive memory metrics for debugging memory leaks
|
||
this.app.get('/api/debug/memory', async () => {
|
||
const mem = process.memoryUsage();
|
||
const subagentStats = subagentWatcher.getStats();
|
||
|
||
// Calculate total Map entries for memory estimation
|
||
const serverMapSizes = {
|
||
sessions: this.sessions.size,
|
||
sseClients: this.sseClients.size,
|
||
respawnControllers: this.respawnControllers.size,
|
||
runSummaryTrackers: this.runSummaryTrackers.size,
|
||
transcriptWatchers: this.transcriptWatchers.size,
|
||
scheduledRuns: this.scheduledRuns.size,
|
||
terminalBatches: this.terminalBatches.size,
|
||
taskUpdateBatches: this.taskUpdateBatches.size,
|
||
stateUpdatePending: this.stateUpdatePending.size,
|
||
lastRecordedTokens: this.lastRecordedTokens.size,
|
||
pendingRespawnStarts: this.pendingRespawnStarts.size,
|
||
respawnTimers: this.respawnTimers.size,
|
||
activePlanOrchestrators: this.activePlanOrchestrators.size,
|
||
cleaningUp: this.cleaningUp.size,
|
||
};
|
||
|
||
const totalServerMapEntries = Object.values(serverMapSizes).reduce((a, b) => a + b, 0);
|
||
const totalSubagentMapEntries = Object.values(subagentStats).reduce((a, b) => a + b, 0);
|
||
|
||
return {
|
||
memory: {
|
||
rss: mem.rss,
|
||
rssMB: Math.round((mem.rss / 1024 / 1024) * 10) / 10,
|
||
heapUsed: mem.heapUsed,
|
||
heapUsedMB: Math.round((mem.heapUsed / 1024 / 1024) * 10) / 10,
|
||
heapTotal: mem.heapTotal,
|
||
heapTotalMB: Math.round((mem.heapTotal / 1024 / 1024) * 10) / 10,
|
||
external: mem.external,
|
||
externalMB: Math.round((mem.external / 1024 / 1024) * 10) / 10,
|
||
arrayBuffers: mem.arrayBuffers,
|
||
arrayBuffersMB: Math.round((mem.arrayBuffers / 1024 / 1024) * 10) / 10,
|
||
},
|
||
mapSizes: {
|
||
server: serverMapSizes,
|
||
subagentWatcher: subagentStats,
|
||
totals: {
|
||
serverEntries: totalServerMapEntries,
|
||
subagentEntries: totalSubagentMapEntries,
|
||
allEntries: totalServerMapEntries + totalSubagentMapEntries,
|
||
},
|
||
},
|
||
watchers: {
|
||
fileWatchers: subagentStats.fileWatcherCount,
|
||
dirWatchers: subagentStats.dirWatcherCount,
|
||
transcriptWatchers: this.transcriptWatchers.size,
|
||
total: subagentStats.fileWatcherCount + subagentStats.dirWatcherCount + this.transcriptWatchers.size,
|
||
},
|
||
timers: {
|
||
respawnTimers: this.respawnTimers.size,
|
||
pendingRespawnStarts: this.pendingRespawnStarts.size,
|
||
subagentIdleTimers: subagentStats.idleTimerCount,
|
||
total: this.respawnTimers.size + this.pendingRespawnStarts.size + subagentStats.idleTimerCount,
|
||
},
|
||
uptime: {
|
||
seconds: Math.round(process.uptime()),
|
||
formatted: formatUptime(process.uptime()),
|
||
},
|
||
timestamp: Date.now(),
|
||
};
|
||
});
|
||
|
||
// Session management
|
||
this.app.get('/api/sessions', async () => this.getLightSessionsState());
|
||
|
||
this.app.post('/api/sessions', async (req) => {
|
||
// Prevent unbounded session creation
|
||
if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
||
return createErrorResponse(
|
||
ApiErrorCode.OPERATION_FAILED,
|
||
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached. Delete some sessions first.`
|
||
);
|
||
}
|
||
|
||
const result = CreateSessionSchema.safeParse(req.body);
|
||
if (!result.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||
}
|
||
const body = result.data;
|
||
const workingDir = body.workingDir || process.cwd();
|
||
|
||
// Validate workingDir exists and is a directory
|
||
if (body.workingDir) {
|
||
try {
|
||
const stat = statSync(workingDir);
|
||
if (!stat.isDirectory()) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
|
||
}
|
||
} catch {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
|
||
}
|
||
}
|
||
|
||
// Write env overrides to .claude/settings.local.json if provided
|
||
if (body.envOverrides && Object.keys(body.envOverrides).length > 0) {
|
||
await updateCaseEnvVars(workingDir, body.envOverrides);
|
||
}
|
||
|
||
// Check OpenCode availability if requested
|
||
if (body.mode === 'opencode') {
|
||
const { isOpenCodeAvailable } = await import('../utils/opencode-cli-resolver.js');
|
||
if (!isOpenCodeAvailable()) {
|
||
return createErrorResponse(
|
||
ApiErrorCode.OPERATION_FAILED,
|
||
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
||
);
|
||
}
|
||
}
|
||
|
||
const globalNice = await this.getGlobalNiceConfig();
|
||
const modelConfig = await this.getModelConfig();
|
||
const mode = body.mode || 'claude';
|
||
const model =
|
||
mode === 'opencode' ? body.openCodeConfig?.model : mode !== 'shell' ? modelConfig?.defaultModel : undefined;
|
||
const claudeModeConfig = await this.getClaudeModeConfig();
|
||
const session = new Session({
|
||
workingDir,
|
||
mode,
|
||
name: body.name || '',
|
||
mux: this.mux,
|
||
useMux: true,
|
||
niceConfig: globalNice,
|
||
model,
|
||
claudeMode: claudeModeConfig.claudeMode,
|
||
allowedTools: claudeModeConfig.allowedTools,
|
||
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
||
});
|
||
|
||
this.sessions.set(session.id, session);
|
||
this.store.incrementSessionsCreated();
|
||
this.persistSessionState(session);
|
||
await this.setupSessionListeners(session);
|
||
getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name });
|
||
|
||
// Use light state for broadcast + response — buffers are fetched on-demand via /terminal.
|
||
// Avoids serializing 2-3MB of terminal+text buffers per session creation.
|
||
const lightState = this.getSessionStateWithRespawn(session);
|
||
this.broadcast('session:created', lightState);
|
||
return { success: true, session: lightState };
|
||
});
|
||
|
||
// Rename a session
|
||
this.app.put('/api/sessions/:id/name', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const result = SessionNameSchema.safeParse(req.body);
|
||
if (!result.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||
}
|
||
const body = result.data;
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH);
|
||
session.name = name;
|
||
// Also update the mux session name if applicable
|
||
this.mux.updateSessionName(id, session.name);
|
||
this.persistSessionState(session);
|
||
this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
|
||
return { success: true, name: session.name };
|
||
});
|
||
|
||
// Set session color
|
||
this.app.put('/api/sessions/:id/color', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const result = SessionColorSchema.safeParse(req.body);
|
||
if (!result.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||
}
|
||
const body = result.data;
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
const validColors = ['default', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink'];
|
||
if (!validColors.includes(body.color)) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid color');
|
||
}
|
||
|
||
session.setColor(body.color as import('../types.js').SessionColor);
|
||
this.persistSessionState(session);
|
||
this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
|
||
return { success: true, color: session.color };
|
||
});
|
||
|
||
this.app.delete('/api/sessions/:id', async (req): Promise<ApiResponse> => {
|
||
const { id } = req.params as { id: string };
|
||
const query = req.query as { killMux?: string };
|
||
const killMux = query.killMux !== 'false'; // Default to true
|
||
|
||
if (!this.sessions.has(id)) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
await this.cleanupSession(id, killMux, 'user_delete');
|
||
return { success: true };
|
||
});
|
||
|
||
// Kill all sessions at once
|
||
this.app.delete('/api/sessions', async (): Promise<ApiResponse<{ killed: number }>> => {
|
||
const sessionIds = Array.from(this.sessions.keys());
|
||
let killed = 0;
|
||
|
||
for (const id of sessionIds) {
|
||
if (this.sessions.has(id)) {
|
||
await this.cleanupSession(id, true, 'user_bulk_delete');
|
||
killed++;
|
||
}
|
||
}
|
||
|
||
return { success: true, data: { killed } };
|
||
});
|
||
|
||
this.app.get('/api/sessions/:id', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
// Use light state (no full buffers) — terminal buffer available via /terminal endpoint.
|
||
// Full buffers were 2-3MB and caused slowness when polled frequently (e.g. Ralph wizard).
|
||
return this.getSessionStateWithRespawn(session);
|
||
});
|
||
|
||
this.app.get('/api/sessions/:id/output', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
textOutput: session.textOutput,
|
||
messages: session.messages,
|
||
errorBuffer: session.errorBuffer,
|
||
},
|
||
};
|
||
});
|
||
|
||
// Get Ralph state (Ralph loop + todos) for a session
|
||
this.app.get('/api/sessions/:id/ralph-state', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
loop: session.ralphLoopState,
|
||
todos: session.ralphTodos,
|
||
todoStats: session.ralphTodoStats,
|
||
},
|
||
};
|
||
});
|
||
|
||
// Get run summary for a session (what happened while you were away)
|
||
this.app.get('/api/sessions/:id/run-summary', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
const tracker = this.runSummaryTrackers.get(id);
|
||
if (!tracker) {
|
||
// Create a fresh tracker if one doesn't exist (shouldn't happen normally)
|
||
const newTracker = new RunSummaryTracker(id, session.name);
|
||
this.runSummaryTrackers.set(id, newTracker);
|
||
return { success: true, summary: newTracker.getSummary() };
|
||
}
|
||
|
||
// Update session name in case it changed
|
||
tracker.setSessionName(session.name);
|
||
|
||
return { success: true, summary: tracker.getSummary() };
|
||
});
|
||
|
||
// Get active Bash tools for a session (file-viewing commands)
|
||
this.app.get('/api/sessions/:id/active-tools', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
tools: session.activeTools,
|
||
},
|
||
};
|
||
});
|
||
|
||
// Get file tree for session's working directory (File Browser)
|
||
this.app.get('/api/sessions/:id/files', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const { depth, showHidden } = req.query as { depth?: string; showHidden?: string };
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
const maxDepth = Math.min(parseInt(depth || '5', 10), 10);
|
||
const includeHidden = showHidden === 'true';
|
||
const workingDir = session.workingDir;
|
||
|
||
// Default excludes - large/generated directories
|
||
const excludeDirs = new Set([
|
||
'.git',
|
||
'node_modules',
|
||
'dist',
|
||
'build',
|
||
'__pycache__',
|
||
'.cache',
|
||
'.next',
|
||
'.nuxt',
|
||
'coverage',
|
||
'.venv',
|
||
'venv',
|
||
'.tox',
|
||
'target',
|
||
'vendor',
|
||
]);
|
||
|
||
interface FileTreeNode {
|
||
name: string;
|
||
path: string;
|
||
type: 'file' | 'directory';
|
||
size?: number;
|
||
extension?: string;
|
||
children?: FileTreeNode[];
|
||
}
|
||
|
||
let totalFiles = 0;
|
||
let totalDirectories = 0;
|
||
let truncated = false;
|
||
const maxFiles = 5000;
|
||
|
||
const scanDirectory = async (dirPath: string, currentDepth: number): Promise<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 (err) {
|
||
// Can't read directory (permission denied, etc.)
|
||
return [];
|
||
}
|
||
};
|
||
|
||
const tree = await scanDirectory(workingDir, 1);
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
root: workingDir,
|
||
tree,
|
||
totalFiles,
|
||
totalDirectories,
|
||
truncated,
|
||
},
|
||
};
|
||
});
|
||
|
||
// Get file content for preview (File Browser)
|
||
this.app.get('/api/sessions/:id/file-content', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const { path: filePath, lines, raw } = req.query as { path?: string; lines?: string; raw?: string };
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
if (!filePath) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter');
|
||
}
|
||
|
||
// Validate path is within working directory (security: proper path traversal check)
|
||
const fullPath = resolve(session.workingDir, filePath);
|
||
const relativePath = relative(session.workingDir, fullPath);
|
||
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory');
|
||
}
|
||
|
||
try {
|
||
const stat = await fs.stat(fullPath);
|
||
|
||
// 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(fullPath, 'utf-8');
|
||
const allLines = content.split('\n');
|
||
const truncatedContent = allLines.length > maxLines;
|
||
const displayContent = truncatedContent ? allLines.slice(0, maxLines).join('\n') : content;
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
path: filePath,
|
||
content: displayContent,
|
||
size: stat.size,
|
||
totalLines: allLines.length,
|
||
truncated: truncatedContent,
|
||
extension: ext,
|
||
},
|
||
};
|
||
} catch (err) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`);
|
||
}
|
||
});
|
||
|
||
// Serve raw file content (for images/binary files)
|
||
this.app.get('/api/sessions/:id/file-raw', async (req, reply) => {
|
||
const { id } = req.params as { id: string };
|
||
const { path: filePath } = req.query as { path?: string };
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'));
|
||
return;
|
||
}
|
||
|
||
if (!filePath) {
|
||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
||
return;
|
||
}
|
||
|
||
// Validate path is within working directory (security: proper path traversal check)
|
||
const fullPath = resolve(session.workingDir, filePath);
|
||
const relativePath = relative(session.workingDir, fullPath);
|
||
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(fullPath);
|
||
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(fullPath);
|
||
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
|
||
reply.send(content);
|
||
} catch (err) {
|
||
reply
|
||
.code(500)
|
||
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
|
||
}
|
||
});
|
||
|
||
// Stream file content via tail -f (SSE endpoint)
|
||
this.app.get('/api/sessions/:id/tail-file', async (req, reply) => {
|
||
const { id } = req.params as { id: string };
|
||
const { path: filePath, lines } = req.query as { path?: string; lines?: string };
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'));
|
||
return;
|
||
}
|
||
|
||
if (!filePath) {
|
||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
||
return;
|
||
}
|
||
|
||
// Set up SSE headers
|
||
reply.raw.writeHead(200, {
|
||
'Content-Type': 'text/event-stream',
|
||
'Cache-Control': 'no-cache',
|
||
Connection: 'keep-alive',
|
||
'X-Accel-Buffering': 'no',
|
||
});
|
||
|
||
// Track stream for cleanup
|
||
const streamRef: { id?: string } = {};
|
||
|
||
// Create the file stream
|
||
const result = await fileStreamManager.createStream({
|
||
sessionId: id,
|
||
filePath,
|
||
workingDir: session.workingDir,
|
||
lines: lines ? parseInt(lines, 10) : undefined,
|
||
onData: (data) => {
|
||
// Send data as SSE event
|
||
reply.raw.write(`data: ${JSON.stringify({ type: 'data', content: data })}\n\n`);
|
||
},
|
||
onEnd: () => {
|
||
reply.raw.write(`data: ${JSON.stringify({ type: 'end' })}\n\n`);
|
||
reply.raw.end();
|
||
},
|
||
onError: (error) => {
|
||
reply.raw.write(`data: ${JSON.stringify({ type: 'error', error })}\n\n`);
|
||
},
|
||
});
|
||
|
||
if (!result.success) {
|
||
reply.raw.write(`data: ${JSON.stringify({ type: 'error', error: result.error })}\n\n`);
|
||
reply.raw.end();
|
||
return;
|
||
}
|
||
|
||
streamRef.id = result.streamId;
|
||
|
||
// Notify client of successful connection
|
||
reply.raw.write(`data: ${JSON.stringify({ type: 'connected', streamId: result.streamId, filePath })}\n\n`);
|
||
|
||
// Handle client disconnect
|
||
req.raw.on('close', () => {
|
||
if (streamRef.id) {
|
||
fileStreamManager.closeStream(streamRef.id);
|
||
}
|
||
});
|
||
});
|
||
|
||
// Close a file stream
|
||
this.app.delete('/api/sessions/:id/tail-file/:streamId', async (req) => {
|
||
const { id, streamId } = req.params as { id: string; streamId: string };
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
const closed = fileStreamManager.closeStream(streamId);
|
||
return { success: closed };
|
||
});
|
||
|
||
// Configure Ralph (Ralph Wiggum) settings
|
||
this.app.post('/api/sessions/:id/ralph-config', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const ralphResult = RalphConfigSchema.safeParse(req.body);
|
||
if (!ralphResult.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||
}
|
||
const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = ralphResult.data as {
|
||
enabled?: boolean;
|
||
completionPhrase?: string;
|
||
maxIterations?: number;
|
||
reset?: boolean | 'full';
|
||
disableAutoEnable?: boolean;
|
||
};
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
// Ralph tracker is not supported for opencode sessions
|
||
if (session.mode === 'opencode') {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Ralph tracker is not supported for opencode sessions');
|
||
}
|
||
|
||
// Handle reset first (before other config)
|
||
if (reset) {
|
||
if (reset === 'full') {
|
||
session.ralphTracker.fullReset();
|
||
} else {
|
||
session.ralphTracker.reset();
|
||
}
|
||
}
|
||
|
||
// Configure auto-enable behavior
|
||
if (disableAutoEnable !== undefined) {
|
||
if (disableAutoEnable) {
|
||
session.ralphTracker.disableAutoEnable();
|
||
} else {
|
||
session.ralphTracker.enableAutoEnable();
|
||
}
|
||
}
|
||
|
||
// Enable/disable the tracker
|
||
if (enabled !== undefined) {
|
||
if (enabled) {
|
||
session.ralphTracker.enable();
|
||
// Allow re-enabling on restart if user explicitly enabled
|
||
session.ralphTracker.enableAutoEnable();
|
||
} else {
|
||
session.ralphTracker.disable();
|
||
// Prevent re-enabling on restart when user explicitly disabled
|
||
session.ralphTracker.disableAutoEnable();
|
||
}
|
||
// Persist Ralph enabled state
|
||
this.mux.updateRalphEnabled(id, enabled);
|
||
}
|
||
|
||
// Configure the Ralph tracker
|
||
if (completionPhrase !== undefined) {
|
||
// Start loop with completion phrase to set it up for watching
|
||
if (completionPhrase) {
|
||
session.ralphTracker.startLoop(completionPhrase, maxIterations || undefined);
|
||
}
|
||
}
|
||
|
||
if (maxIterations !== undefined) {
|
||
session.ralphTracker.setMaxIterations(maxIterations || null);
|
||
}
|
||
|
||
// Persist and broadcast the update
|
||
this.persistSessionState(session);
|
||
this.broadcast('session:ralphLoopUpdate', {
|
||
sessionId: id,
|
||
state: session.ralphLoopState,
|
||
});
|
||
|
||
return { success: true };
|
||
});
|
||
|
||
// Reset circuit breaker for Ralph tracker
|
||
this.app.post('/api/sessions/:id/ralph-circuit-breaker/reset', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
session.ralphTracker.resetCircuitBreaker();
|
||
return { success: true };
|
||
});
|
||
|
||
// Get Ralph status block and circuit breaker state
|
||
this.app.get('/api/sessions/:id/ralph-status', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
lastStatusBlock: session.ralphTracker.lastStatusBlock,
|
||
circuitBreaker: session.ralphTracker.circuitBreakerStatus,
|
||
cumulativeStats: session.ralphTracker.cumulativeStats,
|
||
exitGateMet: session.ralphTracker.exitGateMet,
|
||
},
|
||
};
|
||
});
|
||
|
||
// Generate @fix_plan.md content from todos
|
||
this.app.get('/api/sessions/:id/fix-plan', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
const content = session.ralphTracker.generateFixPlanMarkdown();
|
||
return {
|
||
success: true,
|
||
data: {
|
||
content,
|
||
todoCount: session.ralphTracker.todos.length,
|
||
},
|
||
};
|
||
});
|
||
|
||
// Import todos from @fix_plan.md content
|
||
this.app.post('/api/sessions/:id/fix-plan/import', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const importResult = FixPlanImportSchema.safeParse(req.body);
|
||
if (!importResult.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||
}
|
||
const { content } = importResult.data;
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
const importedCount = session.ralphTracker.importFixPlanMarkdown(content);
|
||
this.persistSessionState(session);
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
importedCount,
|
||
todos: session.ralphTracker.todos,
|
||
},
|
||
};
|
||
});
|
||
|
||
// Write @fix_plan.md to session's working directory
|
||
this.app.post('/api/sessions/:id/fix-plan/write', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
const workingDir = session.workingDir;
|
||
if (!workingDir) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Session has no working directory');
|
||
}
|
||
|
||
const content = session.ralphTracker.generateFixPlanMarkdown();
|
||
const filePath = join(workingDir, '@fix_plan.md');
|
||
|
||
try {
|
||
await fs.writeFile(filePath, content, 'utf-8');
|
||
return {
|
||
success: true,
|
||
data: {
|
||
filePath,
|
||
todoCount: session.ralphTracker.todos.length,
|
||
},
|
||
};
|
||
} catch (error) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to write file: ${error}`);
|
||
}
|
||
});
|
||
|
||
// Read @fix_plan.md from session's working directory and import
|
||
this.app.post('/api/sessions/:id/fix-plan/read', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
const workingDir = session.workingDir;
|
||
if (!workingDir) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Session has no working directory');
|
||
}
|
||
|
||
const filePath = join(workingDir, '@fix_plan.md');
|
||
|
||
try {
|
||
const content = await fs.readFile(filePath, 'utf-8');
|
||
const importedCount = session.ralphTracker.importFixPlanMarkdown(content);
|
||
this.persistSessionState(session);
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
filePath,
|
||
importedCount,
|
||
todos: session.ralphTracker.todos,
|
||
},
|
||
};
|
||
} catch (error) {
|
||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, '@fix_plan.md not found in working directory');
|
||
}
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${error}`);
|
||
}
|
||
});
|
||
|
||
// Write Ralph prompt to file in session's working directory
|
||
// This avoids mux input escaping issues with long multi-line prompts
|
||
this.app.post('/api/sessions/:id/ralph-prompt/write', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const promptResult = RalphPromptWriteSchema.safeParse(req.body);
|
||
if (!promptResult.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||
}
|
||
const { content } = promptResult.data;
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
const workingDir = session.workingDir;
|
||
if (!workingDir) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Session has no working directory');
|
||
}
|
||
|
||
const filePath = join(workingDir, '@ralph_prompt.md');
|
||
|
||
try {
|
||
await fs.writeFile(filePath, content, 'utf-8');
|
||
return {
|
||
success: true,
|
||
data: {
|
||
filePath,
|
||
contentLength: content.length,
|
||
},
|
||
};
|
||
} catch (error) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to write file: ${error}`);
|
||
}
|
||
});
|
||
|
||
// Run prompt in session
|
||
this.app.post('/api/sessions/:id/run', async (req): Promise<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 = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
if (session.isBusy()) {
|
||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||
}
|
||
|
||
// Run async, don't wait
|
||
session.runPrompt(prompt).catch((err) => {
|
||
this.broadcast('session:error', { id, error: err.message });
|
||
});
|
||
|
||
this.broadcast('session:running', { id, prompt });
|
||
return { success: true };
|
||
});
|
||
|
||
// Start interactive Claude session (persists even if browser disconnects)
|
||
this.app.post('/api/sessions/:id/interactive', async (req): Promise<ApiResponse> => {
|
||
const { id } = req.params as { id: string };
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
if (session.isBusy()) {
|
||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||
}
|
||
|
||
try {
|
||
// Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user)
|
||
// Ralph tracker is not supported for opencode sessions
|
||
if (
|
||
session.mode !== 'opencode' &&
|
||
this.store.getConfig().ralphEnabled &&
|
||
!session.ralphTracker.autoEnableDisabled
|
||
) {
|
||
autoConfigureRalph(session, session.workingDir, () => {});
|
||
if (!session.ralphTracker.enabled) {
|
||
session.ralphTracker.enable();
|
||
}
|
||
}
|
||
|
||
await session.startInteractive();
|
||
getLifecycleLog().log({
|
||
event: 'started',
|
||
sessionId: id,
|
||
name: session.name,
|
||
mode: session.mode,
|
||
});
|
||
this.broadcast('session:interactive', { id });
|
||
this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
|
||
|
||
return { success: true };
|
||
} catch (err) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||
}
|
||
});
|
||
|
||
// Start a plain shell session (no Claude)
|
||
this.app.post('/api/sessions/:id/shell', async (req): Promise<ApiResponse> => {
|
||
const { id } = req.params as { id: string };
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
if (session.isBusy()) {
|
||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||
}
|
||
|
||
try {
|
||
await session.startShell();
|
||
getLifecycleLog().log({
|
||
event: 'started',
|
||
sessionId: id,
|
||
name: session.name,
|
||
mode: 'shell',
|
||
});
|
||
this.broadcast('session:interactive', { id, mode: 'shell' });
|
||
this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
|
||
return { success: true };
|
||
} catch (err) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||
}
|
||
});
|
||
|
||
// Send input to interactive session
|
||
// useMux: true uses writeViaMux which is more reliable for programmatic input
|
||
this.app.post('/api/sessions/:id/input', async (req): Promise<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 = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
const inputStr = String(input);
|
||
if (inputStr.length > MAX_INPUT_LENGTH) {
|
||
return createErrorResponse(
|
||
ApiErrorCode.INVALID_INPUT,
|
||
`Input exceeds maximum length (${MAX_INPUT_LENGTH} bytes)`
|
||
);
|
||
}
|
||
|
||
// Write input to PTY. Direct write is synchronous; writeViaMux
|
||
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
|
||
if (useMux) {
|
||
// Fire-and-forget: don't block HTTP response on tmux child process.
|
||
// Fallback to direct write on failure.
|
||
session
|
||
.writeViaMux(inputStr)
|
||
.then((ok) => {
|
||
if (!ok) {
|
||
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
|
||
session.write(inputStr);
|
||
}
|
||
})
|
||
.catch(() => {
|
||
session.write(inputStr);
|
||
});
|
||
} else {
|
||
session.write(inputStr);
|
||
}
|
||
return { success: true };
|
||
});
|
||
|
||
// Resize session terminal
|
||
this.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 = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
// Note: Zod already validates that cols and rows are positive integers within bounds
|
||
if (cols > MAX_TERMINAL_COLS || rows > MAX_TERMINAL_ROWS) {
|
||
return createErrorResponse(
|
||
ApiErrorCode.INVALID_INPUT,
|
||
`Terminal dimensions exceed maximum (${MAX_TERMINAL_COLS}x${MAX_TERMINAL_ROWS})`
|
||
);
|
||
}
|
||
|
||
session.resize(cols, rows);
|
||
return { success: true };
|
||
});
|
||
|
||
// Get session terminal buffer (for reconnecting)
|
||
// Query params:
|
||
// tail=<bytes> - Only return last N bytes (faster initial load)
|
||
this.app.get('/api/sessions/:id/terminal', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const query = req.query as { tail?: string };
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
||
const fullSize = session.terminalBufferLength;
|
||
let truncated = false;
|
||
let cleanBuffer: string;
|
||
|
||
if (tailBytes > 0 && fullSize > tailBytes) {
|
||
// Fast path: tail from the end, skip expensive banner search on full 2MB buffer.
|
||
// Banner is near the top and gets discarded by tail anyway.
|
||
cleanBuffer = session.terminalBuffer.slice(-tailBytes);
|
||
truncated = true;
|
||
// Avoid starting mid-ANSI-escape: find first newline within the first 4KB
|
||
// and start from there. This prevents xterm.js from parsing a partial escape
|
||
// sequence which corrupts cursor position for all subsequent Ink redraws.
|
||
const firstNewline = cleanBuffer.indexOf('\n');
|
||
if (firstNewline > 0 && firstNewline < 4096) {
|
||
cleanBuffer = cleanBuffer.slice(firstNewline + 1);
|
||
}
|
||
} else {
|
||
// Full buffer: clean junk before actual Claude content
|
||
cleanBuffer = session.terminalBuffer;
|
||
|
||
// Find where Claude banner starts (has color codes before "Claude")
|
||
const claudeMatch = cleanBuffer.match(CLAUDE_BANNER_PATTERN);
|
||
if (claudeMatch && claudeMatch.index !== undefined && claudeMatch.index > 0) {
|
||
let lineStart = claudeMatch.index;
|
||
while (lineStart > 0 && cleanBuffer[lineStart - 1] !== '\n') {
|
||
lineStart--;
|
||
}
|
||
cleanBuffer = cleanBuffer.slice(lineStart);
|
||
}
|
||
}
|
||
|
||
// Remove Ctrl+L and leading whitespace (cheap on tailed subset)
|
||
cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '').replace(LEADING_WHITESPACE_PATTERN, '');
|
||
|
||
return {
|
||
terminalBuffer: cleanBuffer,
|
||
status: session.status,
|
||
fullSize,
|
||
truncated,
|
||
};
|
||
});
|
||
|
||
// ============ Respawn Controller Endpoints ============
|
||
|
||
// Get respawn status for a session
|
||
this.app.get('/api/sessions/:id/respawn', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const controller = this.respawnControllers.get(id);
|
||
|
||
if (!controller) {
|
||
return { enabled: false, status: null };
|
||
}
|
||
|
||
return {
|
||
enabled: true,
|
||
...controller.getStatus(),
|
||
};
|
||
});
|
||
|
||
// Get respawn config (from running controller or pre-saved)
|
||
this.app.get('/api/sessions/:id/respawn/config', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const controller = this.respawnControllers.get(id);
|
||
|
||
if (controller) {
|
||
return { success: true, config: controller.getConfig(), active: true };
|
||
}
|
||
|
||
// Return pre-saved config from mux-sessions.json
|
||
const preConfig = this.mux.getSession(id)?.respawnConfig;
|
||
if (preConfig) {
|
||
return { success: true, config: preConfig, active: false };
|
||
}
|
||
|
||
return { success: true, config: null, active: false };
|
||
});
|
||
|
||
// Start respawn controller for a session
|
||
this.app.post('/api/sessions/:id/respawn/start', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
let body: Partial<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 = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
// Respawn is not supported for opencode sessions
|
||
if (session.mode === 'opencode') {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions');
|
||
}
|
||
|
||
// Create or get existing controller
|
||
let controller = this.respawnControllers.get(id);
|
||
if (!controller) {
|
||
// Merge request body with pre-saved config from mux-sessions.json
|
||
const preConfig = this.mux.getSession(id)?.respawnConfig;
|
||
const config = body || preConfig ? { ...preConfig, ...body } : undefined;
|
||
controller = new RespawnController(session, config);
|
||
this.respawnControllers.set(id, controller);
|
||
this.setupRespawnListeners(id, controller);
|
||
} else if (body) {
|
||
controller.updateConfig(body);
|
||
}
|
||
|
||
controller.start();
|
||
|
||
// Persist respawn config to mux session and state.json
|
||
this.saveRespawnConfig(id, controller.getConfig());
|
||
this.persistSessionState(session);
|
||
|
||
this.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() });
|
||
|
||
return { success: true, status: controller.getStatus() };
|
||
});
|
||
|
||
// Stop respawn controller for a session
|
||
this.app.post('/api/sessions/:id/respawn/stop', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const controller = this.respawnControllers.get(id);
|
||
|
||
if (!controller) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Respawn controller not found');
|
||
}
|
||
|
||
controller.stop();
|
||
|
||
// Remove controller from map so persistSessionState doesn't save respawnEnabled: true
|
||
this.respawnControllers.delete(id);
|
||
|
||
// Clear any timed respawn
|
||
const timerInfo = this.respawnTimers.get(id);
|
||
if (timerInfo) {
|
||
clearTimeout(timerInfo.timer);
|
||
this.respawnTimers.delete(id);
|
||
}
|
||
|
||
// Clear persisted respawn config
|
||
this.mux.clearRespawnConfig(id);
|
||
|
||
// Update state.json (respawnConfig removed)
|
||
const session = this.sessions.get(id);
|
||
if (session) {
|
||
this.persistSessionState(session);
|
||
}
|
||
|
||
this.broadcast('respawn:stopped', { sessionId: id });
|
||
|
||
return { success: true };
|
||
});
|
||
|
||
// Update respawn configuration (works with or without running controller)
|
||
this.app.put('/api/sessions/:id/respawn/config', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
// Validate respawn config to prevent arbitrary field injection
|
||
const parseResult = RespawnConfigSchema.safeParse(req.body);
|
||
if (!parseResult.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid respawn config: ${parseResult.error.message}`);
|
||
}
|
||
const config = parseResult.data as Partial<RespawnConfig>;
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
const controller = this.respawnControllers.get(id);
|
||
|
||
if (controller) {
|
||
// Update running controller
|
||
controller.updateConfig(config);
|
||
this.saveRespawnConfig(id, controller.getConfig());
|
||
this.persistSessionState(session);
|
||
this.broadcast('respawn:configUpdated', { sessionId: id, config: controller.getConfig() });
|
||
return { success: true, config: controller.getConfig() };
|
||
}
|
||
|
||
// No controller running - save as pre-config for when respawn starts
|
||
const existing = this.mux.getSession(id);
|
||
const currentConfig = existing?.respawnConfig;
|
||
const merged: PersistedRespawnConfig = {
|
||
enabled: config.enabled ?? currentConfig?.enabled ?? false,
|
||
idleTimeoutMs: config.idleTimeoutMs ?? currentConfig?.idleTimeoutMs ?? 10000,
|
||
updatePrompt: config.updatePrompt ?? currentConfig?.updatePrompt ?? 'update all the docs and CLAUDE.md',
|
||
interStepDelayMs: config.interStepDelayMs ?? currentConfig?.interStepDelayMs ?? 1000,
|
||
sendClear: config.sendClear ?? currentConfig?.sendClear ?? true,
|
||
sendInit: config.sendInit ?? currentConfig?.sendInit ?? true,
|
||
kickstartPrompt: config.kickstartPrompt ?? currentConfig?.kickstartPrompt,
|
||
autoAcceptPrompts: config.autoAcceptPrompts ?? currentConfig?.autoAcceptPrompts ?? true,
|
||
autoAcceptDelayMs: config.autoAcceptDelayMs ?? currentConfig?.autoAcceptDelayMs ?? 8000,
|
||
aiIdleCheckEnabled: config.aiIdleCheckEnabled ?? currentConfig?.aiIdleCheckEnabled ?? true,
|
||
aiIdleCheckModel: config.aiIdleCheckModel ?? currentConfig?.aiIdleCheckModel ?? 'claude-opus-4-5-20251101',
|
||
aiIdleCheckMaxContext: config.aiIdleCheckMaxContext ?? currentConfig?.aiIdleCheckMaxContext ?? 16000,
|
||
aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs ?? 90000,
|
||
aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs ?? 180000,
|
||
aiPlanCheckEnabled: config.aiPlanCheckEnabled ?? currentConfig?.aiPlanCheckEnabled ?? true,
|
||
aiPlanCheckModel: config.aiPlanCheckModel ?? currentConfig?.aiPlanCheckModel ?? 'claude-opus-4-5-20251101',
|
||
aiPlanCheckMaxContext: config.aiPlanCheckMaxContext ?? currentConfig?.aiPlanCheckMaxContext ?? 8000,
|
||
aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs ?? currentConfig?.aiPlanCheckTimeoutMs ?? 60000,
|
||
aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs ?? 30000,
|
||
durationMinutes: currentConfig?.durationMinutes,
|
||
};
|
||
this.mux.updateRespawnConfig(id, merged);
|
||
this.persistSessionState(session);
|
||
this.broadcast('respawn:configUpdated', { sessionId: id, config: merged });
|
||
return { success: true, config: merged };
|
||
});
|
||
|
||
// Start interactive session WITH respawn enabled
|
||
this.app.post('/api/sessions/:id/interactive-respawn', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const irResult = req.body ? InteractiveRespawnSchema.safeParse(req.body) : { success: true as const, data: {} };
|
||
if (!irResult.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||
}
|
||
const body = irResult.data as {
|
||
respawnConfig?: Partial<RespawnConfig>;
|
||
durationMinutes?: number;
|
||
};
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
if (session.isBusy()) {
|
||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||
}
|
||
|
||
// Respawn is not supported for opencode sessions
|
||
if (session.mode === 'opencode') {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions');
|
||
}
|
||
|
||
try {
|
||
// Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user)
|
||
if (this.store.getConfig().ralphEnabled && !session.ralphTracker.autoEnableDisabled) {
|
||
autoConfigureRalph(session, session.workingDir, () => {});
|
||
if (!session.ralphTracker.enabled) {
|
||
session.ralphTracker.enable();
|
||
}
|
||
}
|
||
|
||
// Start interactive session
|
||
await session.startInteractive();
|
||
getLifecycleLog().log({
|
||
event: 'started',
|
||
sessionId: id,
|
||
name: session.name,
|
||
mode: session.mode,
|
||
reason: 'interactive_respawn',
|
||
});
|
||
this.broadcast('session:interactive', { id });
|
||
this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
|
||
|
||
// Create and start respawn controller
|
||
const controller = new RespawnController(session, body?.respawnConfig);
|
||
this.respawnControllers.set(id, controller);
|
||
this.setupRespawnListeners(id, controller);
|
||
controller.start();
|
||
|
||
// Set up timed stop if duration specified
|
||
if (body?.durationMinutes && body.durationMinutes > 0) {
|
||
this.setupTimedRespawn(id, body.durationMinutes);
|
||
}
|
||
|
||
// Persist full session state with respawn config
|
||
this.persistSessionState(session);
|
||
|
||
this.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() });
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
message: 'Interactive session with respawn started',
|
||
respawnStatus: controller.getStatus(),
|
||
},
|
||
};
|
||
} catch (err) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||
}
|
||
});
|
||
|
||
// Enable respawn on an EXISTING interactive session
|
||
this.app.post('/api/sessions/:id/respawn/enable', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const reResult = req.body ? RespawnEnableSchema.safeParse(req.body) : { success: true as const, data: {} };
|
||
if (!reResult.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||
}
|
||
const body = reResult.data as { config?: Partial<RespawnConfig>; durationMinutes?: number };
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
// Respawn is not supported for opencode sessions
|
||
if (session.mode === 'opencode') {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions');
|
||
}
|
||
|
||
// Check if session is running (has a PID)
|
||
if (!session.pid) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Session is not running. Start it first.');
|
||
}
|
||
|
||
// Stop existing controller if any
|
||
const existingController = this.respawnControllers.get(id);
|
||
if (existingController) {
|
||
existingController.stop();
|
||
}
|
||
|
||
// Create and start new respawn controller (merge with pre-saved config)
|
||
const preConfig = this.mux.getSession(id)?.respawnConfig;
|
||
const config = body?.config || preConfig ? { ...preConfig, ...body?.config } : undefined;
|
||
const controller = new RespawnController(session, config);
|
||
this.respawnControllers.set(id, controller);
|
||
this.setupRespawnListeners(id, controller);
|
||
controller.start();
|
||
|
||
// Set up timed stop if duration specified
|
||
if (body?.durationMinutes && body.durationMinutes > 0) {
|
||
this.setupTimedRespawn(id, body.durationMinutes);
|
||
}
|
||
|
||
// Persist respawn config to mux session and state.json
|
||
this.saveRespawnConfig(id, controller.getConfig(), body?.durationMinutes);
|
||
this.persistSessionState(session);
|
||
|
||
this.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() });
|
||
|
||
return {
|
||
success: true,
|
||
message: 'Respawn enabled on existing session',
|
||
respawnStatus: controller.getStatus(),
|
||
};
|
||
});
|
||
|
||
// Set auto-clear on a session
|
||
this.app.post('/api/sessions/:id/auto-clear', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const acResult = AutoClearSchema.safeParse(req.body);
|
||
if (!acResult.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||
}
|
||
const body = acResult.data;
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
session.setAutoClear(body.enabled, body.threshold);
|
||
this.persistSessionState(session);
|
||
this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
autoClear: {
|
||
enabled: session.autoClearEnabled,
|
||
threshold: session.autoClearThreshold,
|
||
},
|
||
},
|
||
};
|
||
});
|
||
|
||
// Set auto-compact on a session
|
||
this.app.post('/api/sessions/:id/auto-compact', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const compactResult = AutoCompactSchema.safeParse(req.body);
|
||
if (!compactResult.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||
}
|
||
const body = compactResult.data;
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
session.setAutoCompact(body.enabled, body.threshold, body.prompt);
|
||
this.persistSessionState(session);
|
||
this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
autoCompact: {
|
||
enabled: session.autoCompactEnabled,
|
||
threshold: session.autoCompactThreshold,
|
||
prompt: session.autoCompactPrompt,
|
||
},
|
||
},
|
||
};
|
||
});
|
||
|
||
// Toggle image watcher for a session
|
||
this.app.post('/api/sessions/:id/image-watcher', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const iwResult = ImageWatcherSchema.safeParse(req.body);
|
||
if (!iwResult.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||
}
|
||
const body = iwResult.data;
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
if (body.enabled) {
|
||
imageWatcher.watchSession(session.id, session.workingDir);
|
||
} else {
|
||
imageWatcher.unwatchSession(session.id);
|
||
}
|
||
|
||
// Store state on session for persistence
|
||
session.imageWatcherEnabled = body.enabled;
|
||
this.persistSessionState(session);
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
imageWatcherEnabled: body.enabled,
|
||
},
|
||
};
|
||
});
|
||
|
||
// Toggle flicker filter for a session
|
||
this.app.post('/api/sessions/:id/flicker-filter', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const ffResult = FlickerFilterSchema.safeParse(req.body);
|
||
if (!ffResult.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||
}
|
||
const body = ffResult.data;
|
||
const session = this.sessions.get(id);
|
||
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
session.flickerFilterEnabled = body.enabled;
|
||
this.persistSessionState(session);
|
||
this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
flickerFilterEnabled: body.enabled,
|
||
},
|
||
};
|
||
});
|
||
|
||
// Quick run (create session, run prompt, return result, then cleanup)
|
||
this.app.post('/api/run', async (req) => {
|
||
// Prevent unbounded session creation
|
||
if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
||
return createErrorResponse(
|
||
ApiErrorCode.SESSION_BUSY,
|
||
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`
|
||
);
|
||
}
|
||
|
||
const qrResult = QuickRunSchema.safeParse(req.body);
|
||
if (!qrResult.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||
}
|
||
const { prompt, workingDir } = qrResult.data;
|
||
|
||
if (!prompt.trim()) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required');
|
||
}
|
||
const dir = workingDir || process.cwd();
|
||
|
||
// Validate workingDir exists and is a directory
|
||
if (workingDir) {
|
||
try {
|
||
const stat = statSync(dir);
|
||
if (!stat.isDirectory()) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
|
||
}
|
||
} catch {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
|
||
}
|
||
}
|
||
|
||
const session = new Session({ workingDir: dir });
|
||
this.sessions.set(session.id, session);
|
||
this.store.incrementSessionsCreated();
|
||
this.persistSessionState(session);
|
||
await this.setupSessionListeners(session);
|
||
getLifecycleLog().log({
|
||
event: 'created',
|
||
sessionId: session.id,
|
||
name: session.name,
|
||
reason: 'run_prompt',
|
||
});
|
||
|
||
this.broadcast('session:created', this.getSessionStateWithRespawn(session));
|
||
|
||
try {
|
||
const result = await session.runPrompt(prompt);
|
||
// Clean up session after completion to prevent memory leak
|
||
await this.cleanupSession(session.id, true, 'run_prompt_complete');
|
||
return { success: true, sessionId: session.id, ...result };
|
||
} catch (err) {
|
||
// Clean up session on error too
|
||
await this.cleanupSession(session.id, true, 'run_prompt_error');
|
||
return { success: false, sessionId: session.id, error: getErrorMessage(err) };
|
||
}
|
||
});
|
||
|
||
// Scheduled runs
|
||
this.app.get('/api/scheduled', async () => {
|
||
return Array.from(this.scheduledRuns.values());
|
||
});
|
||
|
||
this.app.post(
|
||
'/api/scheduled',
|
||
async (req): Promise<{ success: boolean; run: ScheduledRun } | ApiResponse<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 this.startScheduledRun(prompt, workingDir || process.cwd(), durationMinutes ?? 60);
|
||
return { success: true, run };
|
||
}
|
||
);
|
||
|
||
this.app.delete('/api/scheduled/:id', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const run = this.scheduledRuns.get(id);
|
||
|
||
if (!run) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled run not found');
|
||
}
|
||
|
||
await this.stopScheduledRun(id);
|
||
return { success: true };
|
||
});
|
||
|
||
this.app.get('/api/scheduled/:id', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const run = this.scheduledRuns.get(id);
|
||
|
||
if (!run) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled run not found');
|
||
}
|
||
|
||
return run;
|
||
});
|
||
|
||
// Case management
|
||
const casesDir = join(homedir(), 'codeman-cases');
|
||
|
||
this.app.get('/api/cases', async (): Promise<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;
|
||
});
|
||
|
||
this.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 this.getDefaultClaudeMdPath();
|
||
const claudeMd = generateClaudeMd(name, description || '', templatePath);
|
||
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
|
||
|
||
// Write .claude/settings.local.json with hooks for desktop notifications
|
||
await writeHooksConfig(casePath);
|
||
|
||
this.broadcast('case:created', { name, path: casePath });
|
||
|
||
return { success: true, data: { case: { name, path: casePath } } };
|
||
} catch (err) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||
}
|
||
});
|
||
|
||
// Link an existing folder as a case
|
||
this.app.post('/api/cases/link', async (req): Promise<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));
|
||
this.broadcast('case:linked', { name, path: expandedPath });
|
||
return { success: true, data: { case: { name, path: expandedPath } } };
|
||
} catch (err) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||
}
|
||
});
|
||
|
||
this.app.get('/api/cases/:name', async (req) => {
|
||
const { name } = req.params as { name: string };
|
||
|
||
// First check linked cases
|
||
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
|
||
try {
|
||
const linkedCases: Record<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)
|
||
this.app.get('/api/cases/:name/fix-plan', async (req) => {
|
||
const { name } = req.params as { name: string };
|
||
|
||
// Get case path (check linked cases first, then casesDir)
|
||
let casePath: string | null = null;
|
||
|
||
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
|
||
try {
|
||
const linkedCases: Record<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}`);
|
||
}
|
||
});
|
||
|
||
// Quick Start: Create case (if needed) and start interactive session in one click
|
||
this.app.post('/api/quick-start', async (req): Promise<QuickStartResponse> => {
|
||
// Prevent unbounded session creation
|
||
if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
||
return createErrorResponse(
|
||
ApiErrorCode.SESSION_BUSY,
|
||
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.`
|
||
);
|
||
}
|
||
|
||
const result = QuickStartSchema.safeParse(req.body);
|
||
if (!result.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||
}
|
||
const { caseName = 'testcase', mode = 'claude', openCodeConfig } = result.data;
|
||
|
||
// Check OpenCode availability if requested
|
||
if (mode === 'opencode') {
|
||
const { isOpenCodeAvailable } = await import('../utils/opencode-cli-resolver.js');
|
||
if (!isOpenCodeAvailable()) {
|
||
return createErrorResponse(
|
||
ApiErrorCode.OPERATION_FAILED,
|
||
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
||
);
|
||
}
|
||
}
|
||
|
||
const casePath = join(casesDir, caseName);
|
||
|
||
// Security: Path traversal protection - use relative path check
|
||
const resolvedPath = resolve(casePath);
|
||
const resolvedBase = resolve(casesDir);
|
||
const relPath = relative(resolvedBase, resolvedPath);
|
||
if (relPath.startsWith('..') || isAbsolute(relPath)) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||
}
|
||
|
||
// Create case folder and CLAUDE.md if it doesn't exist
|
||
if (!existsSync(casePath)) {
|
||
try {
|
||
mkdirSync(casePath, { recursive: true });
|
||
mkdirSync(join(casePath, 'src'), { recursive: true });
|
||
|
||
// Read settings to get custom template path
|
||
const templatePath = await this.getDefaultClaudeMdPath();
|
||
const claudeMd = generateClaudeMd(caseName, '', templatePath);
|
||
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
|
||
|
||
// Write .claude/settings.local.json with hooks for desktop notifications
|
||
// (Claude-specific — OpenCode uses its own plugin system)
|
||
if (mode !== 'opencode') {
|
||
await writeHooksConfig(casePath);
|
||
}
|
||
|
||
this.broadcast('case:created', { name: caseName, path: casePath });
|
||
} catch (err) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
||
}
|
||
}
|
||
|
||
// Create a new session with the case as working directory
|
||
// Apply global Nice priority config and model config from settings
|
||
const niceConfig = await this.getGlobalNiceConfig();
|
||
const qsModelConfig = await this.getModelConfig();
|
||
const qsModel =
|
||
mode === 'opencode' ? openCodeConfig?.model : mode !== 'shell' ? qsModelConfig?.defaultModel : undefined;
|
||
const qsClaudeModeConfig = await this.getClaudeModeConfig();
|
||
const session = new Session({
|
||
workingDir: casePath,
|
||
mux: this.mux,
|
||
useMux: true,
|
||
mode: mode,
|
||
niceConfig: niceConfig,
|
||
model: qsModel,
|
||
claudeMode: qsClaudeModeConfig.claudeMode,
|
||
allowedTools: qsClaudeModeConfig.allowedTools,
|
||
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
||
});
|
||
|
||
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
||
// so the initial state already has the phrase configured (only if globally enabled)
|
||
if (mode === 'claude' && this.store.getConfig().ralphEnabled) {
|
||
autoConfigureRalph(session, casePath, () => {}); // no broadcast yet
|
||
if (!session.ralphTracker.enabled) {
|
||
session.ralphTracker.enable();
|
||
session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart
|
||
}
|
||
}
|
||
|
||
this.sessions.set(session.id, session);
|
||
this.store.incrementSessionsCreated();
|
||
this.persistSessionState(session);
|
||
await this.setupSessionListeners(session);
|
||
getLifecycleLog().log({
|
||
event: 'created',
|
||
sessionId: session.id,
|
||
name: session.name,
|
||
reason: 'quick_start',
|
||
});
|
||
this.broadcast('session:created', this.getSessionStateWithRespawn(session));
|
||
|
||
// Start in the appropriate mode
|
||
try {
|
||
if (mode === 'shell') {
|
||
await session.startShell();
|
||
getLifecycleLog().log({
|
||
event: 'started',
|
||
sessionId: session.id,
|
||
name: session.name,
|
||
mode: 'shell',
|
||
});
|
||
this.broadcast('session:interactive', { id: session.id, mode: 'shell' });
|
||
} else {
|
||
// Both 'claude' and 'opencode' modes use startInteractive()
|
||
await session.startInteractive();
|
||
getLifecycleLog().log({
|
||
event: 'started',
|
||
sessionId: session.id,
|
||
name: session.name,
|
||
mode,
|
||
});
|
||
this.broadcast('session:interactive', { id: session.id, mode });
|
||
}
|
||
this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
|
||
|
||
// Save lastUsedCase to settings for TUI/web sync
|
||
try {
|
||
const settingsFilePath = join(homedir(), '.codeman', 'settings.json');
|
||
let settings: Record<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 this.cleanupSession(session.id, true, 'quick_start_error');
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||
}
|
||
});
|
||
|
||
// ========== Ralph Loop Start (replaces 6-8 serial API calls from frontend) ==========
|
||
|
||
this.app.post('/api/ralph-loop/start', async (req): Promise<ApiResponse> => {
|
||
// Prevent unbounded session creation
|
||
if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
||
return createErrorResponse(
|
||
ApiErrorCode.SESSION_BUSY,
|
||
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.`
|
||
);
|
||
}
|
||
|
||
const rlResult = RalphLoopStartSchema.safeParse(req.body);
|
||
if (!rlResult.success) {
|
||
return createErrorResponse(
|
||
ApiErrorCode.INVALID_INPUT,
|
||
rlResult.error.issues[0]?.message ?? 'Validation failed'
|
||
);
|
||
}
|
||
const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems } = rlResult.data;
|
||
|
||
const casePath = join(casesDir, caseName);
|
||
|
||
// Security: Path traversal protection
|
||
const rlResolvedPath = resolve(casePath);
|
||
const rlResolvedBase = resolve(casesDir);
|
||
const rlRelPath = relative(rlResolvedBase, rlResolvedPath);
|
||
if (rlRelPath.startsWith('..') || isAbsolute(rlRelPath)) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||
}
|
||
|
||
// Create case folder if it doesn't exist (reuse quick-start logic)
|
||
if (!existsSync(casePath)) {
|
||
try {
|
||
mkdirSync(casePath, { recursive: true });
|
||
mkdirSync(join(casePath, 'src'), { recursive: true });
|
||
const templatePath = await this.getDefaultClaudeMdPath();
|
||
const claudeMd = generateClaudeMd(caseName, '', templatePath);
|
||
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
|
||
await writeHooksConfig(casePath);
|
||
this.broadcast('case:created', { name: caseName, path: casePath });
|
||
} catch (err) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
||
}
|
||
}
|
||
|
||
// Create session
|
||
const niceConfig = await this.getGlobalNiceConfig();
|
||
const rlModelConfig = await this.getModelConfig();
|
||
const rlClaudeModeConfig = await this.getClaudeModeConfig();
|
||
const session = new Session({
|
||
workingDir: casePath,
|
||
mux: this.mux,
|
||
useMux: true,
|
||
mode: 'claude',
|
||
niceConfig,
|
||
model: rlModelConfig?.defaultModel,
|
||
claudeMode: rlClaudeModeConfig.claudeMode,
|
||
allowedTools: rlClaudeModeConfig.allowedTools,
|
||
});
|
||
|
||
// Configure Ralph tracker
|
||
autoConfigureRalph(session, casePath, () => {});
|
||
if (!session.ralphTracker.enabled) {
|
||
session.ralphTracker.enable();
|
||
session.ralphTracker.enableAutoEnable();
|
||
}
|
||
session.ralphTracker.startLoop(completionPhrase, maxIterations ?? undefined);
|
||
|
||
// Build fix_plan markdown from plan items if provided
|
||
const enabledItems = planItems?.filter((i) => i.enabled) ?? [];
|
||
let planContent = '';
|
||
if (enabledItems.length > 0) {
|
||
const p0 = enabledItems.filter((i) => i.priority === 'P0');
|
||
const p1 = enabledItems.filter((i) => i.priority === 'P1');
|
||
const p2 = enabledItems.filter((i) => i.priority === 'P2');
|
||
const noPri = enabledItems.filter((i) => !i.priority);
|
||
planContent = '# Implementation Plan\n\n';
|
||
planContent += `Generated: ${new Date().toISOString().slice(0, 10)}\n\n`;
|
||
if (p0.length > 0) {
|
||
planContent += '## Critical Path (P0)\n\n';
|
||
p0.forEach((i) => {
|
||
planContent += `- [ ] ${i.content}\n`;
|
||
});
|
||
planContent += '\n';
|
||
}
|
||
if (p1.length > 0) {
|
||
planContent += '## Standard (P1)\n\n';
|
||
p1.forEach((i) => {
|
||
planContent += `- [ ] ${i.content}\n`;
|
||
});
|
||
planContent += '\n';
|
||
}
|
||
if (p2.length > 0) {
|
||
planContent += '## Nice-to-Have (P2)\n\n';
|
||
p2.forEach((i) => {
|
||
planContent += `- [ ] ${i.content}\n`;
|
||
});
|
||
planContent += '\n';
|
||
}
|
||
if (noPri.length > 0) {
|
||
planContent += '## Tasks\n\n';
|
||
noPri.forEach((i) => {
|
||
planContent += `- [ ] ${i.content}\n`;
|
||
});
|
||
planContent += '\n';
|
||
}
|
||
|
||
// Import into tracker and write to disk
|
||
session.ralphTracker.importFixPlanMarkdown(planContent);
|
||
const fixPlanPath = join(casePath, '@fix_plan.md');
|
||
writeFileSync(fixPlanPath, planContent, 'utf-8');
|
||
}
|
||
|
||
// Build full prompt
|
||
const hasPlan = enabledItems.length > 0;
|
||
let fullPrompt = taskDescription + '\n\n---\n\n';
|
||
if (hasPlan) {
|
||
fullPrompt += '## Task Plan\n\n';
|
||
fullPrompt += 'A task plan has been written to `@fix_plan.md`. Use this to track progress:\n';
|
||
fullPrompt += '- Reference the plan at the start of each iteration\n';
|
||
fullPrompt += '- Update task checkboxes as you complete items\n';
|
||
fullPrompt += '- Work through items in priority order (P0 > P1 > P2)\n\n';
|
||
}
|
||
fullPrompt += '## Iteration Protocol\n\n';
|
||
fullPrompt += 'This is an autonomous loop. Files from previous iterations persist. On each iteration:\n';
|
||
fullPrompt += '1. Check what work has already been done\n';
|
||
fullPrompt += '2. Make incremental progress toward completion\n';
|
||
fullPrompt += '3. Commit meaningful changes with descriptive messages\n\n';
|
||
fullPrompt += '## Verification\n\n';
|
||
fullPrompt += 'After each significant change:\n';
|
||
fullPrompt += '- Run tests to verify (npm test, pytest, etc.)\n';
|
||
fullPrompt += '- Check for type/lint errors if applicable\n';
|
||
fullPrompt += '- If tests fail, read the error, fix it, and retry\n\n';
|
||
fullPrompt += '## Completion Criteria\n\n';
|
||
fullPrompt += `Output \`<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
|
||
this.sessions.set(session.id, session);
|
||
this.store.incrementSessionsCreated();
|
||
this.persistSessionState(session);
|
||
await this.setupSessionListeners(session);
|
||
getLifecycleLog().log({
|
||
event: 'created',
|
||
sessionId: session.id,
|
||
name: session.name,
|
||
reason: 'ralph_loop_start',
|
||
});
|
||
this.broadcast('session:created', this.getSessionStateWithRespawn(session));
|
||
|
||
// Start interactive mode
|
||
try {
|
||
await session.startInteractive();
|
||
getLifecycleLog().log({
|
||
event: 'started',
|
||
sessionId: session.id,
|
||
name: session.name,
|
||
mode: 'claude',
|
||
});
|
||
this.broadcast('session:interactive', { id: session.id, mode: 'claude' });
|
||
this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
|
||
} catch (err) {
|
||
await this.cleanupSession(session.id, true, 'ralph_loop_start_error');
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||
}
|
||
|
||
// Enable respawn if requested
|
||
if (enableRespawn) {
|
||
const ralphUpdatePrompt =
|
||
'Before /clear: Update CLAUDE.md with discoveries and notes, mark completed tasks in @fix_plan.md, write a brief progress summary to a file so the next iteration can continue seamlessly.';
|
||
const ralphKickstartPrompt = `You are in a Ralph Wiggum loop. Read @fix_plan.md for task status, continue on the next uncompleted task, output <promise>${completionPhrase}</promise> when ALL tasks are complete.`;
|
||
const controller = new RespawnController(session, {
|
||
updatePrompt: ralphUpdatePrompt,
|
||
sendClear: true,
|
||
sendInit: true,
|
||
kickstartPrompt: ralphKickstartPrompt,
|
||
});
|
||
this.respawnControllers.set(session.id, controller);
|
||
this.setupRespawnListeners(session.id, controller);
|
||
controller.start();
|
||
this.saveRespawnConfig(session.id, controller.getConfig());
|
||
this.persistSessionState(session);
|
||
this.broadcast('respawn:started', {
|
||
sessionId: session.id,
|
||
status: controller.getStatus(),
|
||
});
|
||
}
|
||
|
||
// Save lastUsedCase
|
||
try {
|
||
const settingsFilePath = join(homedir(), '.codeman', 'settings.json');
|
||
let settings: Record<string, unknown> = {};
|
||
try {
|
||
settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
settings.lastUsedCase = caseName;
|
||
const dir = dirname(settingsFilePath);
|
||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||
fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch(() => {});
|
||
} catch {
|
||
/* non-critical */
|
||
}
|
||
|
||
const sessionId = session.id;
|
||
|
||
// Async: poll for CLI readiness, then send prompt
|
||
setImmediate(() => {
|
||
const pollReady = async () => {
|
||
for (let attempt = 0; attempt < 60; attempt++) {
|
||
await new Promise((r) => setTimeout(r, 500));
|
||
const s = this.sessions.get(sessionId);
|
||
if (!s) return; // session was deleted
|
||
// Check terminal output for prompt indicator
|
||
const termBuf = s.getTerminalBuffer().slice(-2048);
|
||
if (termBuf.includes('❯') || termBuf.includes('tokens')) {
|
||
break;
|
||
}
|
||
}
|
||
// Small extra delay for CLI to settle
|
||
await new Promise((r) => setTimeout(r, 2000));
|
||
const s = this.sessions.get(sessionId);
|
||
if (!s) return;
|
||
try {
|
||
await s.writeViaMux('Read @ralph_prompt.md and follow the instructions. Start working immediately.\r');
|
||
} catch (err) {
|
||
console.warn(`[RalphLoop] Failed to send prompt to session ${sessionId}:`, getErrorMessage(err));
|
||
}
|
||
};
|
||
pollReady().catch((err) => console.error('[RalphLoop] pollReady error:', err));
|
||
});
|
||
|
||
return {
|
||
success: true,
|
||
data: { sessionId, caseName },
|
||
};
|
||
});
|
||
|
||
// Use enhanced PlanItem from orchestrator (has verification, dependencies, tracking)
|
||
type PlanItem = import('../plan-orchestrator.js').PlanItem;
|
||
|
||
this.app.post('/api/generate-plan', async (req): Promise<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: this.mux,
|
||
useMux: false, // No mux needed for one-shot
|
||
mode: 'claude',
|
||
});
|
||
|
||
// Use configured model for plan generation, falling back to opus
|
||
const planModelConfig = await this.getModelConfig();
|
||
const modelToUse = planModelConfig?.agentTypeOverrides?.implement || planModelConfig?.defaultModel || 'opus';
|
||
|
||
try {
|
||
const { result, cost } = await session.runPrompt(prompt, { model: modelToUse });
|
||
|
||
// Parse JSON from result
|
||
const jsonMatch = result.match(/\[[\s\S]*\]/);
|
||
if (!jsonMatch) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Failed to parse plan - no JSON array found');
|
||
}
|
||
|
||
let items: PlanItem[];
|
||
try {
|
||
const parsed = JSON.parse(jsonMatch[0]);
|
||
if (!Array.isArray(parsed)) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Invalid response - expected array');
|
||
}
|
||
|
||
// Validate and normalize items with enhanced fields
|
||
items = parsed.map((item: unknown, idx: number) => {
|
||
if (typeof item !== 'object' || item === null) {
|
||
return {
|
||
id: `task-${idx}`,
|
||
content: `Step ${idx + 1}`,
|
||
priority: null,
|
||
verificationCriteria: 'Task completed successfully',
|
||
status: 'pending' as const,
|
||
attempts: 0,
|
||
version: 1,
|
||
};
|
||
}
|
||
const obj = item as Record<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
|
||
this.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 this.getModelConfig();
|
||
const orchestrator = new PlanOrchestrator(this.mux, process.cwd(), outputDir, detailedModelConfig ?? undefined);
|
||
|
||
// Store orchestrator for potential cancellation via API (not on disconnect)
|
||
// Plan generation continues even if browser disconnects - only explicit cancel stops it
|
||
const orchestratorId = `plan-${Date.now()}`;
|
||
this.activePlanOrchestrators.set(orchestratorId, orchestrator);
|
||
|
||
// Broadcast the orchestrator ID so frontend can cancel if needed
|
||
this.broadcast('plan:started', { orchestratorId });
|
||
|
||
// Track progress for SSE updates
|
||
const progressUpdates: Array<{ phase: string; detail: string; timestamp: number }> = [];
|
||
const onProgress = (phase: string, detail: string) => {
|
||
const update = { phase, detail, timestamp: Date.now() };
|
||
progressUpdates.push(update);
|
||
// Broadcast progress to connected clients
|
||
this.broadcast('plan:progress', update);
|
||
};
|
||
|
||
// Broadcast plan subagent events for UI visibility
|
||
const onSubagent = (event: {
|
||
type: string;
|
||
agentId: string;
|
||
agentType: string;
|
||
model: string;
|
||
status: string;
|
||
detail?: string;
|
||
itemCount?: number;
|
||
durationMs?: number;
|
||
error?: string;
|
||
}) => {
|
||
this.broadcast('plan:subagent', event);
|
||
};
|
||
|
||
try {
|
||
const result: DetailedPlanResult = await orchestrator.generateDetailedPlan(
|
||
taskDescription,
|
||
onProgress,
|
||
onSubagent
|
||
);
|
||
|
||
// Clean up orchestrator from active map
|
||
this.activePlanOrchestrators.delete(orchestratorId);
|
||
this.broadcast('plan:completed', { orchestratorId, success: result.success });
|
||
|
||
if (!result.success) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, result.error || 'Plan generation failed');
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
items: result.items,
|
||
costUsd: result.costUsd,
|
||
metadata: result.metadata,
|
||
progressLog: progressUpdates,
|
||
orchestratorId,
|
||
},
|
||
};
|
||
} catch (err) {
|
||
// Clean up on error too
|
||
this.activePlanOrchestrators.delete(orchestratorId);
|
||
this.broadcast('plan:completed', {
|
||
orchestratorId,
|
||
success: false,
|
||
error: getErrorMessage(err),
|
||
});
|
||
return createErrorResponse(
|
||
ApiErrorCode.OPERATION_FAILED,
|
||
'Detailed plan generation failed: ' + getErrorMessage(err)
|
||
);
|
||
}
|
||
});
|
||
|
||
// Cancel active plan generation
|
||
this.app.post('/api/cancel-plan-generation', async (req): Promise<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 = this.activePlanOrchestrators.get(orchestratorId);
|
||
if (!orchestrator) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Plan generation not found or already completed');
|
||
}
|
||
console.log(`[API] Cancelling plan generation ${orchestratorId}`);
|
||
await orchestrator.cancel();
|
||
this.activePlanOrchestrators.delete(orchestratorId);
|
||
this.broadcast('plan:cancelled', { orchestratorId });
|
||
return { success: true, data: { cancelled: orchestratorId } };
|
||
}
|
||
|
||
// Otherwise cancel all active plan generations
|
||
const cancelled: string[] = [];
|
||
for (const [id, orchestrator] of this.activePlanOrchestrators) {
|
||
console.log(`[API] Cancelling plan generation ${id}`);
|
||
await orchestrator.cancel();
|
||
cancelled.push(id);
|
||
this.broadcast('plan:cancelled', { orchestratorId: id });
|
||
}
|
||
this.activePlanOrchestrators.clear();
|
||
|
||
return { success: true, data: { cancelled } };
|
||
});
|
||
|
||
// Get ralph-wizard files for a case (prompts and results)
|
||
this.app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => {
|
||
const { caseName } = req.params as { caseName: string };
|
||
const casesDir = join(homedir(), 'codeman-cases');
|
||
let casePath = join(casesDir, caseName);
|
||
|
||
// Security: Path traversal protection - use relative path check
|
||
const resolvedCase = resolve(casePath);
|
||
const resolvedBase = resolve(casesDir);
|
||
const relPath = relative(resolvedBase, resolvedCase);
|
||
if (relPath.startsWith('..') || isAbsolute(relPath)) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||
}
|
||
|
||
// Check linked cases if path doesn't exist
|
||
if (!existsSync(casePath)) {
|
||
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
|
||
try {
|
||
const linkedCases: Record<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
|
||
this.app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req, reply) => {
|
||
const { caseName, filePath } = req.params as { caseName: string; filePath: string };
|
||
const casesDir = join(homedir(), 'codeman-cases');
|
||
let casePath = join(casesDir, caseName);
|
||
|
||
// Prevent browser caching - prompts change between plan generations
|
||
reply.header('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||
reply.header('Pragma', 'no-cache');
|
||
reply.header('Expires', '0');
|
||
|
||
// Security: Path traversal protection for case name - use relative path check
|
||
const resolvedCase = resolve(casePath);
|
||
const resolvedBase = resolve(casesDir);
|
||
const relPath = relative(resolvedBase, resolvedCase);
|
||
if (relPath.startsWith('..') || isAbsolute(relPath)) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||
}
|
||
|
||
// Check linked cases if path doesn't exist
|
||
if (!existsSync(casePath)) {
|
||
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
|
||
try {
|
||
const linkedCases: Record<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,
|
||
},
|
||
};
|
||
});
|
||
|
||
// ============ Plan Management Endpoints ============
|
||
// These endpoints support runtime plan adaptation with checkpoints, failure tracking, and versioning
|
||
|
||
// Update a specific plan task (status, attempts, errors)
|
||
this.app.patch('/api/sessions/:id/plan/task/:taskId', async (req) => {
|
||
const { id, taskId } = req.params as { id: string; taskId: string };
|
||
const session = this.sessions.get(id);
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
const tracker = session.ralphTracker;
|
||
if (!tracker) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
|
||
}
|
||
|
||
const ptuResult = PlanTaskUpdateSchema.safeParse(req.body);
|
||
if (!ptuResult.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||
}
|
||
const update = ptuResult.data as {
|
||
status?: 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked';
|
||
error?: string;
|
||
incrementAttempts?: boolean;
|
||
};
|
||
|
||
const result = tracker.updatePlanTask(taskId, update);
|
||
if (!result.success) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, result.error || 'Task not found');
|
||
}
|
||
|
||
this.broadcast('session:planTaskUpdate', { sessionId: id, taskId, update: result.task });
|
||
return { success: true, data: result.task };
|
||
});
|
||
|
||
// Trigger a checkpoint review (at iterations 5, 10, 20, etc.)
|
||
this.app.post('/api/sessions/:id/plan/checkpoint', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const session = this.sessions.get(id);
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
const tracker = session.ralphTracker;
|
||
if (!tracker) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
|
||
}
|
||
|
||
const checkpoint = tracker.generateCheckpointReview();
|
||
this.broadcast('session:planCheckpoint', { sessionId: id, checkpoint });
|
||
return { success: true, data: checkpoint };
|
||
});
|
||
|
||
// Get plan version history
|
||
this.app.get('/api/sessions/:id/plan/history', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const session = this.sessions.get(id);
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
const tracker = session.ralphTracker;
|
||
if (!tracker) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
|
||
}
|
||
|
||
return { success: true, data: tracker.getPlanHistory() };
|
||
});
|
||
|
||
// Rollback to a previous plan version
|
||
this.app.post('/api/sessions/:id/plan/rollback/:version', async (req) => {
|
||
const { id, version } = req.params as { id: string; version: string };
|
||
const session = this.sessions.get(id);
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
const tracker = session.ralphTracker;
|
||
if (!tracker) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
|
||
}
|
||
|
||
const result = tracker.rollbackToVersion(parseInt(version, 10));
|
||
if (!result.success) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, result.error || 'Version not found');
|
||
}
|
||
|
||
this.broadcast('session:planRollback', { sessionId: id, version: parseInt(version, 10) });
|
||
return { success: true, data: result.plan };
|
||
});
|
||
|
||
// Add a new task to the plan (for runtime adaptation)
|
||
this.app.post('/api/sessions/:id/plan/task', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const session = this.sessions.get(id);
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
const tracker = session.ralphTracker;
|
||
if (!tracker) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
|
||
}
|
||
|
||
const ptaResult = PlanTaskAddSchema.safeParse(req.body);
|
||
if (!ptaResult.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||
}
|
||
const task = ptaResult.data;
|
||
|
||
const result = tracker.addPlanTask(task);
|
||
this.broadcast('session:planTaskAdded', { sessionId: id, task: result.task });
|
||
return { success: true, data: result.task };
|
||
});
|
||
|
||
// ============ App Settings Endpoints ============
|
||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
||
|
||
this.app.get('/api/settings', async () => {
|
||
try {
|
||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||
return JSON.parse(content);
|
||
} catch (err) {
|
||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||
console.error('Failed to read settings:', err);
|
||
}
|
||
}
|
||
return {};
|
||
});
|
||
|
||
this.app.put('/api/settings', async (req) => {
|
||
const settingsResult = SettingsUpdateSchema.safeParse(req.body);
|
||
if (!settingsResult.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid settings');
|
||
}
|
||
const settings = settingsResult.data as Record<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 this.sessions.values()) {
|
||
if (session.imageWatcherEnabled) {
|
||
imageWatcher.watchSession(session.id, session.workingDir);
|
||
}
|
||
}
|
||
console.log('Image watcher started via settings change');
|
||
} else if (!imageWatcherEnabled && imageWatcher.isRunning()) {
|
||
imageWatcher.stop();
|
||
console.log('Image watcher stopped via settings change');
|
||
}
|
||
|
||
// Handle tunnel toggle dynamically
|
||
if ('tunnelEnabled' in settings) {
|
||
const tunnelEnabled = settings.tunnelEnabled as boolean;
|
||
if (tunnelEnabled && !this.tunnelManager.isRunning()) {
|
||
this.tunnelManager.start(this.port, this.https);
|
||
console.log('Tunnel started via settings change');
|
||
} else if (tunnelEnabled && this.tunnelManager.isRunning() && this.tunnelManager.getUrl()) {
|
||
// Tunnel already running — re-emit so the client gets the URL
|
||
this.broadcast('tunnel:started', { url: this.tunnelManager.getUrl() });
|
||
console.log('Tunnel already running, re-broadcast URL to client');
|
||
} else if (!tunnelEnabled && this.tunnelManager.isRunning()) {
|
||
this.tunnelManager.stop();
|
||
console.log('Tunnel stopped via settings change');
|
||
}
|
||
}
|
||
|
||
return { success: true };
|
||
} catch (err) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||
}
|
||
});
|
||
|
||
// ============ Model Configuration Endpoints ============
|
||
|
||
this.app.get('/api/execution/model-config', async () => {
|
||
try {
|
||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||
const settings = JSON.parse(content);
|
||
return { success: true, data: settings.modelConfig || {} };
|
||
} catch (err) {
|
||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||
console.error('Failed to read model config:', err);
|
||
}
|
||
return { success: true, data: {} };
|
||
}
|
||
});
|
||
|
||
this.app.put('/api/execution/model-config', async (req) => {
|
||
const mcResult = ModelConfigUpdateSchema.safeParse(req.body);
|
||
if (!mcResult.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid model config');
|
||
}
|
||
const modelConfig = mcResult.data as Record<string, unknown>;
|
||
|
||
try {
|
||
let settings: Record<string, unknown> = {};
|
||
try {
|
||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||
settings = JSON.parse(content);
|
||
} catch {
|
||
// File doesn't exist yet, start fresh
|
||
}
|
||
|
||
settings.modelConfig = modelConfig;
|
||
|
||
const dir = dirname(settingsPath);
|
||
if (!existsSync(dir)) {
|
||
mkdirSync(dir, { recursive: true });
|
||
}
|
||
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
|
||
|
||
return { success: true };
|
||
} catch (err) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||
}
|
||
});
|
||
|
||
// ============ CPU Priority Endpoints ============
|
||
|
||
// Get Nice priority config for a session
|
||
this.app.get('/api/sessions/:id/cpu-limit', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const session = this.sessions.get(id);
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
return {
|
||
success: true,
|
||
nice: session.niceConfig,
|
||
};
|
||
});
|
||
|
||
// Update Nice priority config for a session
|
||
// Note: Changes only apply to NEW sessions, not running ones
|
||
this.app.post('/api/sessions/:id/cpu-limit', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const session = this.sessions.get(id);
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
const clResult = CpuLimitSchema.safeParse(req.body);
|
||
if (!clResult.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||
}
|
||
const body = clResult.data as Partial<NiceConfig>;
|
||
|
||
session.setNice(body);
|
||
this.persistSessionState(session);
|
||
this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
|
||
|
||
return {
|
||
success: true,
|
||
nice: session.niceConfig,
|
||
note: 'Nice priority only affects newly created mux sessions, not currently running ones.',
|
||
};
|
||
});
|
||
|
||
// ============ Subagent Window State Endpoints ============
|
||
// Persists minimized/open window states for cross-browser sync
|
||
const windowStatesPath = join(homedir(), '.codeman', 'subagent-window-states.json');
|
||
|
||
this.app.get('/api/subagent-window-states', async () => {
|
||
try {
|
||
const content = await fs.readFile(windowStatesPath, 'utf-8');
|
||
return JSON.parse(content);
|
||
} catch (err) {
|
||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||
console.error('Failed to read subagent window states:', err);
|
||
}
|
||
}
|
||
return { minimized: {}, open: [] };
|
||
});
|
||
|
||
this.app.put('/api/subagent-window-states', async (req) => {
|
||
const swResult = SubagentWindowStatesSchema.safeParse(req.body);
|
||
if (!swResult.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid window states');
|
||
}
|
||
const states = swResult.data as Record<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 ============
|
||
// Persists which TAB each agent window connects to.
|
||
// This is the PERMANENT record of agent -> tab associations.
|
||
const parentMapPath = join(homedir(), '.codeman', 'subagent-parents.json');
|
||
|
||
this.app.get('/api/subagent-parents', async () => {
|
||
try {
|
||
const content = await fs.readFile(parentMapPath, 'utf-8');
|
||
return JSON.parse(content);
|
||
} catch (err) {
|
||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||
console.error('Failed to read subagent parent map:', err);
|
||
}
|
||
}
|
||
return {};
|
||
});
|
||
|
||
this.app.put('/api/subagent-parents', async (req) => {
|
||
const spResult = SubagentParentMapSchema.safeParse(req.body);
|
||
if (!spResult.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid parent map');
|
||
}
|
||
const parentMap = spResult.data;
|
||
try {
|
||
const dir = dirname(parentMapPath);
|
||
if (!existsSync(dir)) {
|
||
mkdirSync(dir, { recursive: true });
|
||
}
|
||
await fs.writeFile(parentMapPath, JSON.stringify(parentMap, null, 2));
|
||
return { success: true };
|
||
} catch (err) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||
}
|
||
});
|
||
|
||
// ============ Mux Session Management Endpoints ============
|
||
|
||
// Get all tracked mux sessions with stats
|
||
this.app.get('/api/mux-sessions', async () => {
|
||
const sessions = await this.mux.getSessionsWithStats();
|
||
return {
|
||
sessions,
|
||
muxAvailable: this.mux.isAvailable(),
|
||
};
|
||
});
|
||
|
||
// Kill a mux session
|
||
this.app.delete('/api/mux-sessions/:sessionId', async (req) => {
|
||
const { sessionId } = req.params as { sessionId: string };
|
||
const success = await this.mux.killSession(sessionId);
|
||
return { success };
|
||
});
|
||
|
||
// Reconcile mux sessions (find dead ones)
|
||
this.app.post('/api/mux-sessions/reconcile', async () => {
|
||
const result = await this.mux.reconcileSessions();
|
||
return result;
|
||
});
|
||
|
||
// Start stats collection
|
||
this.app.post('/api/mux-sessions/stats/start', async () => {
|
||
this.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
|
||
return { success: true };
|
||
});
|
||
|
||
// Stop stats collection
|
||
this.app.post('/api/mux-sessions/stats/stop', async () => {
|
||
this.mux.stopStatsCollection();
|
||
return { success: true };
|
||
});
|
||
|
||
// System stats endpoint for frontend header display
|
||
this.app.get('/api/system/stats', async () => {
|
||
return this.getSystemStats();
|
||
});
|
||
|
||
// ========== Subagent Monitoring (Claude Code Background Agents) ==========
|
||
|
||
// List all known subagents
|
||
this.app.get('/api/subagents', async (req) => {
|
||
const { minutes } = req.query as { minutes?: string };
|
||
const subagents = minutes
|
||
? subagentWatcher.getRecentSubagents(parseInt(minutes, 10))
|
||
: subagentWatcher.getSubagents();
|
||
return { success: true, data: subagents };
|
||
});
|
||
|
||
// Get subagents for a specific session (by working directory)
|
||
this.app.get('/api/sessions/:id/subagents', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const session = this.sessions.get(id);
|
||
if (!session) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`);
|
||
}
|
||
const subagents = subagentWatcher.getSubagentsForSession(session.workingDir);
|
||
return { success: true, data: subagents };
|
||
});
|
||
|
||
// Get a specific subagent's info
|
||
this.app.get('/api/subagents/:agentId', async (req) => {
|
||
const { agentId } = req.params as { agentId: string };
|
||
const info = subagentWatcher.getSubagent(agentId);
|
||
if (!info) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Subagent ${agentId} not found`);
|
||
}
|
||
return { success: true, data: info };
|
||
});
|
||
|
||
// Get a subagent's transcript
|
||
this.app.get('/api/subagents/:agentId/transcript', async (req) => {
|
||
const { agentId } = req.params as { agentId: string };
|
||
const { limit, format } = req.query as { limit?: string; format?: 'raw' | 'formatted' };
|
||
const limitNum = limit ? parseInt(limit, 10) : undefined;
|
||
const transcript = await subagentWatcher.getTranscript(agentId, limitNum);
|
||
|
||
if (format === 'formatted') {
|
||
const formatted = subagentWatcher.formatTranscript(transcript);
|
||
return { success: true, data: { formatted, entryCount: transcript.length } };
|
||
}
|
||
|
||
return { success: true, data: transcript };
|
||
});
|
||
|
||
// Kill a subagent
|
||
this.app.delete('/api/subagents/:agentId', async (req) => {
|
||
const { agentId } = req.params as { agentId: string };
|
||
const info = subagentWatcher.getSubagent(agentId);
|
||
if (!info) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subagent not found');
|
||
}
|
||
|
||
const killed = await subagentWatcher.killSubagent(agentId);
|
||
if (killed) {
|
||
return { success: true, data: { agentId, status: 'killed' } };
|
||
}
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Subagent not found or already completed');
|
||
});
|
||
|
||
// Trigger cleanup of stale subagents
|
||
this.app.post('/api/subagents/cleanup', async () => {
|
||
const removed = subagentWatcher.cleanupNow();
|
||
return { success: true, data: { removed, remaining: subagentWatcher.getSubagents().length } };
|
||
});
|
||
|
||
// Clear all tracked subagents (memory only - does not delete files)
|
||
this.app.delete('/api/subagents', async () => {
|
||
const cleared = subagentWatcher.clearAll();
|
||
return { success: true, data: { cleared } };
|
||
});
|
||
|
||
// ========== Agent Teams ==========
|
||
|
||
// List all discovered teams
|
||
this.app.get('/api/teams', async () => {
|
||
return { success: true, data: this.teamWatcher.getTeams() };
|
||
});
|
||
|
||
// Get tasks for a specific team
|
||
this.app.get('/api/teams/:name/tasks', async (req) => {
|
||
const { name } = req.params as { name: string };
|
||
return { success: true, data: this.teamWatcher.getTeamTasks(name) };
|
||
});
|
||
|
||
// ========== Hook Events ==========
|
||
|
||
this.app.post('/api/hook-event', async (req) => {
|
||
const result = HookEventSchema.safeParse(req.body);
|
||
if (!result.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||
}
|
||
const { event, sessionId, data } = result.data;
|
||
if (!this.sessions.has(sessionId)) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||
}
|
||
|
||
// Signal the respawn controller based on hook event type
|
||
const controller = this.respawnControllers.get(sessionId);
|
||
if (controller) {
|
||
if (event === 'elicitation_dialog') {
|
||
// Block auto-accept for question prompts
|
||
controller.signalElicitation();
|
||
} else if (event === 'stop') {
|
||
// DEFINITIVE idle signal - Claude finished responding
|
||
controller.signalStopHook();
|
||
} else if (event === 'idle_prompt') {
|
||
// DEFINITIVE idle signal - Claude has been idle for 60+ seconds
|
||
controller.signalIdlePrompt();
|
||
}
|
||
}
|
||
|
||
// Start transcript watching if transcript_path is provided and safe
|
||
if (data && 'transcript_path' in data) {
|
||
const transcriptPath = String(data.transcript_path);
|
||
if (transcriptPath && isValidWorkingDir(transcriptPath)) {
|
||
this.startTranscriptWatcher(sessionId, transcriptPath);
|
||
}
|
||
}
|
||
|
||
// Sanitize forwarded data: only include known safe fields, limit size
|
||
const safeData = sanitizeHookData(data);
|
||
this.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now(), ...safeData });
|
||
|
||
// Send push notifications for hook events
|
||
const session = this.sessions.get(sessionId);
|
||
const sessionName = session?.name ?? sessionId.slice(0, 8);
|
||
this.sendPushNotifications(`hook:${event}`, { sessionId, sessionName, ...safeData });
|
||
|
||
// Track in run summary
|
||
const summaryTracker = this.runSummaryTrackers.get(sessionId);
|
||
if (summaryTracker) {
|
||
summaryTracker.recordHookEvent(event, safeData);
|
||
}
|
||
|
||
return { success: true };
|
||
});
|
||
|
||
// ========== Web Push ==========
|
||
|
||
this.app.get('/api/push/vapid-key', async () => {
|
||
return { success: true, data: { publicKey: this.pushStore.getPublicKey() } };
|
||
});
|
||
|
||
this.app.post('/api/push/subscribe', async (req) => {
|
||
const result = PushSubscribeSchema.safeParse(req.body);
|
||
if (!result.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||
}
|
||
const { endpoint, keys, userAgent, pushPreferences } = result.data;
|
||
const record = this.pushStore.addSubscription({
|
||
id: uuidv4(),
|
||
endpoint,
|
||
keys,
|
||
userAgent: userAgent ?? req.headers['user-agent'] ?? '',
|
||
createdAt: Date.now(),
|
||
pushPreferences: pushPreferences ?? {},
|
||
});
|
||
return { success: true, data: { id: record.id } };
|
||
});
|
||
|
||
this.app.put('/api/push/subscribe/:id', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const result = PushPreferencesUpdateSchema.safeParse(req.body);
|
||
if (!result.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||
}
|
||
const updated = this.pushStore.updatePreferences(id, result.data.pushPreferences);
|
||
if (!updated) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found');
|
||
}
|
||
return { success: true };
|
||
});
|
||
|
||
this.app.delete('/api/push/subscribe/:id', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const removed = this.pushStore.removeSubscription(id);
|
||
if (!removed) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found');
|
||
}
|
||
return { success: true };
|
||
});
|
||
|
||
// Screenshot upload endpoint (accepts multipart/form-data)
|
||
// Upload form served as static file: /upload.html (src/web/public/upload.html)
|
||
this.app.post('/api/screenshots', async (req, reply) => {
|
||
const contentType = req.headers['content-type'] ?? '';
|
||
if (!contentType.includes('multipart/form-data')) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
|
||
}
|
||
|
||
// Parse multipart boundary
|
||
const boundaryMatch = contentType.match(/boundary=(.+?)(?:;|$)/);
|
||
if (!boundaryMatch) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing boundary');
|
||
}
|
||
|
||
// Collect raw body
|
||
const chunks: Buffer[] = [];
|
||
let totalSize = 0;
|
||
for await (const chunk of req.raw) {
|
||
totalSize += chunk.length;
|
||
if (totalSize > MAX_SCREENSHOT_SIZE) {
|
||
reply.status(413);
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'File too large (max 10MB)');
|
||
}
|
||
chunks.push(chunk as Buffer);
|
||
}
|
||
const body = Buffer.concat(chunks);
|
||
|
||
// Extract file from multipart body
|
||
const boundary = '--' + boundaryMatch[1];
|
||
const boundaryBuf = Buffer.from(boundary);
|
||
const parts: { headers: string; data: Buffer }[] = [];
|
||
let pos = 0;
|
||
|
||
// Find each part between boundaries
|
||
while (pos < body.length) {
|
||
const start = body.indexOf(boundaryBuf, pos);
|
||
if (start === -1) break;
|
||
const afterBoundary = start + boundaryBuf.length;
|
||
// Check for closing boundary (--)
|
||
if (body[afterBoundary] === 0x2d && body[afterBoundary + 1] === 0x2d) break;
|
||
// Skip \r\n after boundary
|
||
const headerStart = afterBoundary + 2;
|
||
const headerEnd = body.indexOf(Buffer.from('\r\n\r\n'), headerStart);
|
||
if (headerEnd === -1) break;
|
||
const headers = body.subarray(headerStart, headerEnd).toString();
|
||
const dataStart = headerEnd + 4;
|
||
const nextBoundary = body.indexOf(boundaryBuf, dataStart);
|
||
// Data ends 2 bytes before next boundary (\r\n)
|
||
const dataEnd = nextBoundary === -1 ? body.length : nextBoundary - 2;
|
||
parts.push({ headers, data: body.subarray(dataStart, dataEnd) });
|
||
pos = nextBoundary === -1 ? body.length : nextBoundary;
|
||
}
|
||
|
||
const filePart = parts.find((p) => p.headers.includes('name="file"'));
|
||
if (!filePart || filePart.data.length === 0) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No file uploaded');
|
||
}
|
||
|
||
// Determine extension from Content-Type or filename
|
||
let ext = '.png';
|
||
const filenameMatch = filePart.headers.match(/filename="(.+?)"/);
|
||
if (filenameMatch) {
|
||
const origExt = filenameMatch[1].match(/\.(png|jpg|jpeg|webp|gif)$/i);
|
||
if (origExt) ext = origExt[0].toLowerCase();
|
||
}
|
||
const ctMatch = filePart.headers.match(/Content-Type:\s*image\/(png|jpeg|webp|gif)/i);
|
||
if (ctMatch) {
|
||
const map: Record<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 };
|
||
});
|
||
|
||
// List screenshots
|
||
this.app.get('/api/screenshots', async () => {
|
||
if (!existsSync(SCREENSHOTS_DIR)) {
|
||
return { files: [] };
|
||
}
|
||
const files = readdirSync(SCREENSHOTS_DIR)
|
||
.filter((f) => /\.(png|jpg|jpeg|webp|gif)$/i.test(f))
|
||
.sort()
|
||
.reverse()
|
||
.slice(0, 50)
|
||
.map((name) => ({ name, path: join(SCREENSHOTS_DIR, name) }));
|
||
return { files };
|
||
});
|
||
|
||
// Serve individual screenshot
|
||
this.app.get('/api/screenshots/:name', async (req, reply) => {
|
||
const { name } = req.params as { name: string };
|
||
// Prevent path traversal
|
||
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
|
||
reply.status(400);
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid filename');
|
||
}
|
||
const filepath = join(SCREENSHOTS_DIR, name);
|
||
if (!existsSync(filepath)) {
|
||
reply.status(404);
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Screenshot not found');
|
||
}
|
||
const ext = name.match(/\.(png|jpg|jpeg|webp|gif)$/i)?.[1]?.toLowerCase() ?? 'png';
|
||
const mimeMap: Record<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);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Start a transcript watcher for a session.
|
||
* Creates a new watcher or updates an existing one with the new transcript path.
|
||
*/
|
||
private startTranscriptWatcher(sessionId: string, transcriptPath: string): void {
|
||
let watcher = this.transcriptWatchers.get(sessionId);
|
||
|
||
if (!watcher) {
|
||
watcher = new TranscriptWatcher();
|
||
|
||
// Wire up transcript events to the respawn controller
|
||
watcher.on('transcript:complete', () => {
|
||
const controller = this.respawnControllers.get(sessionId);
|
||
if (controller) {
|
||
controller.signalTranscriptComplete();
|
||
}
|
||
this.broadcast('transcript:complete', { sessionId, timestamp: Date.now() });
|
||
});
|
||
|
||
watcher.on('transcript:plan_mode', () => {
|
||
const controller = this.respawnControllers.get(sessionId);
|
||
if (controller) {
|
||
controller.signalTranscriptPlanMode();
|
||
}
|
||
this.broadcast('transcript:plan_mode', { sessionId, timestamp: Date.now() });
|
||
});
|
||
|
||
watcher.on('transcript:tool_start', (toolName: string) => {
|
||
this.broadcast('transcript:tool_start', { sessionId, toolName, timestamp: Date.now() });
|
||
});
|
||
|
||
watcher.on('transcript:tool_end', (toolName: string, isError: boolean) => {
|
||
this.broadcast('transcript:tool_end', {
|
||
sessionId,
|
||
toolName,
|
||
isError,
|
||
timestamp: Date.now(),
|
||
});
|
||
});
|
||
|
||
watcher.on('transcript:error', (error: Error) => {
|
||
console.error(`[Transcript] Error for session ${sessionId}:`, error.message);
|
||
});
|
||
|
||
this.transcriptWatchers.set(sessionId, watcher);
|
||
}
|
||
|
||
// Start or update the watcher with the transcript path
|
||
watcher.updatePath(transcriptPath);
|
||
}
|
||
|
||
/**
|
||
* Stop the transcript watcher for a session.
|
||
*/
|
||
private stopTranscriptWatcher(sessionId: string): void {
|
||
const watcher = this.transcriptWatchers.get(sessionId);
|
||
if (watcher) {
|
||
watcher.removeAllListeners(); // Prevent memory leaks from attached listeners
|
||
watcher.stop();
|
||
this.transcriptWatchers.delete(sessionId);
|
||
}
|
||
}
|
||
|
||
/** Debounced wrapper — coalesces rapid persistSessionState calls per session */
|
||
private persistSessionState(session: Session): void {
|
||
const existing = this.persistDebounceTimers.get(session.id);
|
||
if (existing) clearTimeout(existing);
|
||
this.persistDebounceTimers.set(
|
||
session.id,
|
||
setTimeout(() => {
|
||
this.persistDebounceTimers.delete(session.id);
|
||
// Session may have been removed during debounce
|
||
if (this.sessions.has(session.id)) {
|
||
this._persistSessionStateNow(session);
|
||
}
|
||
}, 100)
|
||
);
|
||
}
|
||
|
||
/** Persists full session state including respawn config to state.json */
|
||
private _persistSessionStateNow(session: Session): void {
|
||
const state = session.toState();
|
||
const controller = this.respawnControllers.get(session.id);
|
||
if (controller) {
|
||
const config = controller.getConfig();
|
||
const timerInfo = this.respawnTimers.get(session.id);
|
||
const durationMinutes = timerInfo ? Math.round((timerInfo.endAt - timerInfo.startedAt) / 60000) : undefined;
|
||
state.respawnConfig = { ...config, durationMinutes };
|
||
// Use config.enabled instead of controller.state - this way the respawn
|
||
// will be restored on server restart even if it was temporarily stopped
|
||
// due to errors. Intentional stops via /respawn/stop call clearRespawnConfig().
|
||
state.respawnEnabled = config.enabled;
|
||
} else {
|
||
// Don't overwrite respawnConfig if it exists in state - preserve it for restart
|
||
const existingState = this.store.getSession(session.id);
|
||
if (existingState?.respawnConfig) {
|
||
state.respawnConfig = existingState.respawnConfig;
|
||
state.respawnEnabled = existingState.respawnConfig.enabled ?? false;
|
||
} else {
|
||
state.respawnEnabled = false;
|
||
}
|
||
}
|
||
this.store.setSession(session.id, state);
|
||
}
|
||
|
||
// Helper to save respawn config to mux session for persistence
|
||
private saveRespawnConfig(sessionId: string, config: RespawnConfig, durationMinutes?: number): void {
|
||
const persistedConfig: PersistedRespawnConfig = {
|
||
enabled: config.enabled,
|
||
idleTimeoutMs: config.idleTimeoutMs,
|
||
updatePrompt: config.updatePrompt,
|
||
interStepDelayMs: config.interStepDelayMs,
|
||
sendClear: config.sendClear,
|
||
sendInit: config.sendInit,
|
||
kickstartPrompt: config.kickstartPrompt,
|
||
autoAcceptPrompts: config.autoAcceptPrompts,
|
||
autoAcceptDelayMs: config.autoAcceptDelayMs,
|
||
completionConfirmMs: config.completionConfirmMs,
|
||
noOutputTimeoutMs: config.noOutputTimeoutMs,
|
||
aiIdleCheckEnabled: config.aiIdleCheckEnabled,
|
||
aiIdleCheckModel: config.aiIdleCheckModel,
|
||
aiIdleCheckMaxContext: config.aiIdleCheckMaxContext,
|
||
aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs,
|
||
aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs,
|
||
aiPlanCheckEnabled: config.aiPlanCheckEnabled,
|
||
aiPlanCheckModel: config.aiPlanCheckModel,
|
||
aiPlanCheckMaxContext: config.aiPlanCheckMaxContext,
|
||
aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs,
|
||
aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs,
|
||
durationMinutes,
|
||
};
|
||
this.mux.updateRespawnConfig(sessionId, persistedConfig);
|
||
}
|
||
|
||
// Get system CPU and memory usage
|
||
private getSystemStats(): {
|
||
cpu: number;
|
||
memory: { usedMB: number; totalMB: number; percent: number };
|
||
} {
|
||
try {
|
||
const totalMem = totalmem();
|
||
|
||
// macOS: os.freemem() only returns truly free pages, not cached/purgeable memory.
|
||
// Use vm_stat to get accurate used memory (wired + active + compressed).
|
||
let usedMem: number;
|
||
if (process.platform === 'darwin') {
|
||
try {
|
||
const vmstat = execSync('vm_stat', { encoding: 'utf-8', timeout: 2000 });
|
||
const pageSize = parseInt(vmstat.match(/page size of (\d+)/)?.[1] || '4096', 10);
|
||
const wired = parseInt(vmstat.match(/Pages wired down:\s+(\d+)/)?.[1] || '0', 10);
|
||
const active = parseInt(vmstat.match(/Pages active:\s+(\d+)/)?.[1] || '0', 10);
|
||
const compressed = parseInt(vmstat.match(/Pages occupied by compressor:\s+(\d+)/)?.[1] || '0', 10);
|
||
usedMem = (wired + active + compressed) * pageSize;
|
||
} catch {
|
||
usedMem = totalMem - freemem();
|
||
}
|
||
} else {
|
||
usedMem = totalMem - freemem();
|
||
}
|
||
|
||
// CPU load average (1 min) as percentage (rough approximation)
|
||
const load = loadavg()[0];
|
||
const cpuCount = WebServer.CPU_COUNT;
|
||
const cpuPercent = Math.min(100, Math.round((load / cpuCount) * 100));
|
||
|
||
return {
|
||
cpu: cpuPercent,
|
||
memory: {
|
||
usedMB: Math.round(usedMem / (1024 * 1024)),
|
||
totalMB: Math.round(totalMem / (1024 * 1024)),
|
||
percent: Math.round((usedMem / totalMem) * 100),
|
||
},
|
||
};
|
||
} catch {
|
||
return {
|
||
cpu: 0,
|
||
memory: { usedMB: 0, totalMB: 0, percent: 0 },
|
||
};
|
||
}
|
||
}
|
||
|
||
// Clean up all resources associated with a session
|
||
// Track sessions currently being cleaned up to prevent concurrent cleanup races
|
||
private cleaningUp: Set<string> = new Set();
|
||
|
||
private async cleanupSession(sessionId: string, killMux: boolean = true, reason?: string): Promise<void> {
|
||
// Guard against concurrent cleanup of the same session
|
||
if (this.cleaningUp.has(sessionId)) return;
|
||
this.cleaningUp.add(sessionId);
|
||
|
||
try {
|
||
await this._doCleanupSession(sessionId, killMux, reason);
|
||
} finally {
|
||
this.cleaningUp.delete(sessionId);
|
||
}
|
||
}
|
||
|
||
private async _doCleanupSession(sessionId: string, killMux: boolean, reason?: string): Promise<void> {
|
||
const session = this.sessions.get(sessionId);
|
||
const lifecycleLog = getLifecycleLog();
|
||
lifecycleLog.log({
|
||
event: killMux ? 'deleted' : 'detached',
|
||
sessionId,
|
||
name: session?.name,
|
||
mode: session?.mode,
|
||
reason: reason || 'unknown',
|
||
});
|
||
|
||
// Stop watching @fix_plan.md for this session
|
||
if (session) {
|
||
session.ralphTracker.stopWatchingFixPlan();
|
||
}
|
||
|
||
// Kill all subagents spawned by this session (scoped to sessionId to avoid cross-session kills)
|
||
if (session && killMux) {
|
||
try {
|
||
await subagentWatcher.killSubagentsForSession(session.workingDir, sessionId);
|
||
} catch (err) {
|
||
console.error(`[Server] Failed to kill subagents for session ${sessionId}:`, err);
|
||
}
|
||
}
|
||
|
||
// Stop and remove respawn controller - but save config first for restart recovery
|
||
const controller = this.respawnControllers.get(sessionId);
|
||
if (controller) {
|
||
// Save the config BEFORE removing controller, so it can be restored on restart
|
||
const config = controller.getConfig();
|
||
const timerInfo = this.respawnTimers.get(sessionId);
|
||
const durationMinutes = timerInfo ? Math.round((timerInfo.endAt - timerInfo.startedAt) / 60000) : undefined;
|
||
this.saveRespawnConfig(sessionId, config, durationMinutes);
|
||
|
||
controller.stop();
|
||
controller.removeAllListeners();
|
||
this.respawnControllers.delete(sessionId);
|
||
// Notify UI that respawn is stopped for this session
|
||
this.broadcast('respawn:stopped', { sessionId, reason: 'session_cleanup' });
|
||
}
|
||
|
||
// Clear respawn timer
|
||
const timerInfo = this.respawnTimers.get(sessionId);
|
||
if (timerInfo) {
|
||
clearTimeout(timerInfo.timer);
|
||
this.respawnTimers.delete(sessionId);
|
||
}
|
||
|
||
// Clear pending respawn start timer (from restoration grace period)
|
||
const pendingStart = this.pendingRespawnStarts.get(sessionId);
|
||
if (pendingStart) {
|
||
clearTimeout(pendingStart);
|
||
this.pendingRespawnStarts.delete(sessionId);
|
||
}
|
||
|
||
// Stop transcript watcher
|
||
this.stopTranscriptWatcher(sessionId);
|
||
|
||
// Stop and remove run summary tracker
|
||
const summaryTracker = this.runSummaryTrackers.get(sessionId);
|
||
if (summaryTracker) {
|
||
summaryTracker.recordSessionStopped();
|
||
summaryTracker.stop();
|
||
this.runSummaryTrackers.delete(sessionId);
|
||
}
|
||
|
||
// Clear pending persist-debounce timer (prevents stale closure holding session ref)
|
||
const pendingPersist = this.persistDebounceTimers.get(sessionId);
|
||
if (pendingPersist) {
|
||
clearTimeout(pendingPersist);
|
||
this.persistDebounceTimers.delete(sessionId);
|
||
}
|
||
|
||
// Clear batches, per-session timers, and pending state updates
|
||
this.terminalBatches.delete(sessionId);
|
||
this.terminalBatchSizes.delete(sessionId);
|
||
const batchTimer = this.terminalBatchTimers.get(sessionId);
|
||
if (batchTimer) {
|
||
clearTimeout(batchTimer);
|
||
this.terminalBatchTimers.delete(sessionId);
|
||
}
|
||
this.taskUpdateBatches.delete(sessionId);
|
||
this.stateUpdatePending.delete(sessionId);
|
||
this.lastTerminalEventTime.delete(sessionId);
|
||
|
||
// Reset Ralph tracker on the session before cleanup
|
||
if (session) {
|
||
session.ralphTracker.fullReset();
|
||
}
|
||
|
||
// Clear Ralph state from store
|
||
this.store.removeRalphState(sessionId);
|
||
|
||
// Broadcast Ralph cleared to update UI
|
||
this.broadcast('session:ralphLoopUpdate', {
|
||
sessionId,
|
||
state: {
|
||
enabled: false,
|
||
active: false,
|
||
completionPhrase: null,
|
||
startedAt: null,
|
||
cycleCount: 0,
|
||
maxIterations: null,
|
||
lastActivity: Date.now(),
|
||
elapsedHours: null,
|
||
},
|
||
});
|
||
this.broadcast('session:ralphTodoUpdate', {
|
||
sessionId,
|
||
todos: [],
|
||
stats: { total: 0, pending: 0, inProgress: 0, completed: 0 },
|
||
});
|
||
|
||
// Stop session and remove listeners
|
||
if (session) {
|
||
// Accumulate tokens to global stats before removing session
|
||
// This preserves lifetime usage even after sessions are deleted
|
||
if (killMux && (session.inputTokens > 0 || session.outputTokens > 0 || session.totalCost > 0)) {
|
||
this.store.addToGlobalStats(session.inputTokens, session.outputTokens, session.totalCost);
|
||
// Record to daily stats (for what hasn't been recorded yet via periodic recording)
|
||
const lastRecorded = this.lastRecordedTokens.get(sessionId) || { input: 0, output: 0 };
|
||
const deltaInput = session.inputTokens - lastRecorded.input;
|
||
const deltaOutput = session.outputTokens - lastRecorded.output;
|
||
if (deltaInput > 0 || deltaOutput > 0) {
|
||
this.store.recordDailyUsage(deltaInput, deltaOutput, sessionId);
|
||
}
|
||
this.lastRecordedTokens.delete(sessionId);
|
||
console.log(
|
||
`[Server] Added to global stats: ${session.inputTokens + session.outputTokens} tokens, $${session.totalCost.toFixed(4)} from session ${sessionId}`
|
||
);
|
||
}
|
||
|
||
// Explicitly remove stored listeners to break closure references (prevents memory leak)
|
||
const listeners = this.sessionListenerRefs.get(sessionId);
|
||
if (listeners) {
|
||
session.off('terminal', listeners.terminal);
|
||
session.off('clearTerminal', listeners.clearTerminal);
|
||
session.off('needsRefresh', listeners.needsRefresh);
|
||
session.off('message', listeners.message);
|
||
session.off('error', listeners.error);
|
||
session.off('completion', listeners.completion);
|
||
session.off('exit', listeners.exit);
|
||
session.off('working', listeners.working);
|
||
session.off('idle', listeners.idle);
|
||
session.off('taskCreated', listeners.taskCreated);
|
||
session.off('taskUpdated', listeners.taskUpdated);
|
||
session.off('taskCompleted', listeners.taskCompleted);
|
||
session.off('taskFailed', listeners.taskFailed);
|
||
session.off('autoClear', listeners.autoClear);
|
||
session.off('autoCompact', listeners.autoCompact);
|
||
session.off('cliInfoUpdated', listeners.cliInfoUpdated);
|
||
session.off('ralphLoopUpdate', listeners.ralphLoopUpdate);
|
||
session.off('ralphTodoUpdate', listeners.ralphTodoUpdate);
|
||
session.off('ralphCompletionDetected', listeners.ralphCompletionDetected);
|
||
session.off('ralphStatusBlockDetected', listeners.ralphStatusBlockDetected);
|
||
session.off('ralphCircuitBreakerUpdate', listeners.ralphCircuitBreakerUpdate);
|
||
session.off('ralphExitGateMet', listeners.ralphExitGateMet);
|
||
session.off('bashToolStart', listeners.bashToolStart);
|
||
session.off('bashToolEnd', listeners.bashToolEnd);
|
||
session.off('bashToolsUpdate', listeners.bashToolsUpdate);
|
||
this.sessionListenerRefs.delete(sessionId);
|
||
}
|
||
|
||
session.removeAllListeners();
|
||
// Close any active file streams for this session
|
||
fileStreamManager.closeSessionStreams(sessionId);
|
||
// Stop watching for images in this session's directory
|
||
imageWatcher.unwatchSession(sessionId);
|
||
await session.stop(killMux);
|
||
this.sessions.delete(sessionId);
|
||
// Only remove from state.json if we're also killing the mux session.
|
||
// When killMux=false (server shutdown), preserve state for recovery.
|
||
if (killMux) {
|
||
this.store.removeSession(sessionId);
|
||
}
|
||
}
|
||
|
||
this.broadcast('session:deleted', { id: sessionId });
|
||
}
|
||
|
||
private async setupSessionListeners(session: Session): Promise<void> {
|
||
// Create run summary tracker for this session
|
||
const summaryTracker = new RunSummaryTracker(session.id, session.name);
|
||
this.runSummaryTrackers.set(session.id, summaryTracker);
|
||
summaryTracker.recordSessionStarted(session.mode, session.workingDir);
|
||
|
||
// Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for opencode sessions)
|
||
if (session.mode !== 'opencode') {
|
||
session.ralphTracker.setWorkingDir(session.workingDir);
|
||
}
|
||
|
||
// Start watching for new images in this session's working directory (if enabled globally and per-session)
|
||
if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled) {
|
||
imageWatcher.watchSession(session.id, session.workingDir);
|
||
}
|
||
|
||
// Store all listener references for explicit cleanup on session delete
|
||
// This prevents memory leaks from closure references keeping objects alive
|
||
const listeners: SessionListenerRefs = {
|
||
terminal: (data) => {
|
||
// Use batching for better performance at high throughput
|
||
this.batchTerminalData(session.id, data);
|
||
},
|
||
|
||
clearTerminal: () => {
|
||
// Tell clients to clear their terminal (after mux attach)
|
||
this.broadcast('session:clearTerminal', { id: session.id });
|
||
},
|
||
|
||
needsRefresh: () => {
|
||
// Tell clients to reload the terminal buffer (e.g., after OpenCode TUI stabilizes)
|
||
this.broadcast('session:needsRefresh', { id: session.id });
|
||
},
|
||
|
||
message: (msg: ClaudeMessage) => {
|
||
this.broadcast('session:message', { id: session.id, message: msg });
|
||
},
|
||
|
||
error: (error) => {
|
||
this.broadcast('session:error', { id: session.id, error });
|
||
this.sendPushNotifications('session:error', {
|
||
sessionId: session.id,
|
||
sessionName: session.name,
|
||
error: String(error),
|
||
});
|
||
// Track in run summary
|
||
const tracker = this.runSummaryTrackers.get(session.id);
|
||
if (tracker) tracker.recordError('Session error', String(error));
|
||
},
|
||
|
||
completion: (result, cost) => {
|
||
this.broadcast('session:completion', { id: session.id, result, cost });
|
||
this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
|
||
this.persistSessionState(session);
|
||
// Track tokens in run summary (completion event has updated token values)
|
||
const tracker = this.runSummaryTrackers.get(session.id);
|
||
if (tracker) tracker.recordTokens(session.inputTokens, session.outputTokens);
|
||
},
|
||
|
||
exit: (code) => {
|
||
getLifecycleLog().log({
|
||
event: 'exit',
|
||
sessionId: session.id,
|
||
name: session.name,
|
||
exitCode: code,
|
||
});
|
||
// Wrap in try/catch to ensure cleanup always happens
|
||
try {
|
||
this.broadcast('session:exit', { id: session.id, code });
|
||
this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
|
||
this.persistSessionState(session);
|
||
} catch (err) {
|
||
console.error(`[Server] Error broadcasting session exit for ${session.id}:`, err);
|
||
}
|
||
|
||
// Always clean up respawn controller, even if broadcast failed
|
||
try {
|
||
const controller = this.respawnControllers.get(session.id);
|
||
if (controller) {
|
||
controller.stop();
|
||
controller.removeAllListeners();
|
||
this.respawnControllers.delete(session.id);
|
||
}
|
||
// Also clean up the respawn timer to prevent orphaned timers
|
||
const timerInfo = this.respawnTimers.get(session.id);
|
||
if (timerInfo) {
|
||
clearTimeout(timerInfo.timer);
|
||
this.respawnTimers.delete(session.id);
|
||
}
|
||
} catch (err) {
|
||
console.error(`[Server] Error cleaning up respawn controller for ${session.id}:`, err);
|
||
}
|
||
},
|
||
|
||
working: () => {
|
||
this.broadcast('session:working', { id: session.id });
|
||
// Track in run summary
|
||
const tracker = this.runSummaryTrackers.get(session.id);
|
||
if (tracker) {
|
||
tracker.recordWorking();
|
||
tracker.recordTokens(session.inputTokens, session.outputTokens);
|
||
}
|
||
},
|
||
|
||
idle: () => {
|
||
this.broadcast('session:idle', { id: session.id });
|
||
// Use debounced state update (idle can fire frequently)
|
||
this.broadcastSessionStateDebounced(session.id);
|
||
// Track in run summary
|
||
const tracker = this.runSummaryTrackers.get(session.id);
|
||
if (tracker) {
|
||
tracker.recordIdle();
|
||
tracker.recordTokens(session.inputTokens, session.outputTokens);
|
||
}
|
||
},
|
||
|
||
// Background task events - use debounced state updates to reduce serialization overhead
|
||
taskCreated: (task: BackgroundTask) => {
|
||
this.broadcast('task:created', { sessionId: session.id, task });
|
||
this.broadcastSessionStateDebounced(session.id);
|
||
},
|
||
|
||
taskUpdated: (task: BackgroundTask) => {
|
||
// Use batching for better performance at high update rates
|
||
this.batchTaskUpdate(session.id, task);
|
||
},
|
||
|
||
taskCompleted: (task: BackgroundTask) => {
|
||
this.broadcast('task:completed', { sessionId: session.id, task });
|
||
this.broadcastSessionStateDebounced(session.id);
|
||
},
|
||
|
||
taskFailed: (task: BackgroundTask, error: string) => {
|
||
this.broadcast('task:failed', { sessionId: session.id, task, error });
|
||
this.broadcastSessionStateDebounced(session.id);
|
||
},
|
||
|
||
autoClear: (data: { tokens: number; threshold: number }) => {
|
||
this.broadcast('session:autoClear', { sessionId: session.id, ...data });
|
||
this.broadcastSessionStateDebounced(session.id);
|
||
// Track in run summary
|
||
const tracker = this.runSummaryTrackers.get(session.id);
|
||
if (tracker) tracker.recordAutoClear(data.tokens, data.threshold);
|
||
},
|
||
|
||
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => {
|
||
this.broadcast('session:autoCompact', { sessionId: session.id, ...data });
|
||
this.broadcastSessionStateDebounced(session.id);
|
||
// Track in run summary
|
||
const tracker = this.runSummaryTrackers.get(session.id);
|
||
if (tracker) tracker.recordAutoCompact(data.tokens, data.threshold);
|
||
},
|
||
|
||
// Claude Code CLI info parsed from terminal (version, model, account)
|
||
cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => {
|
||
this.broadcast('session:cliInfo', { sessionId: session.id, ...data });
|
||
this.broadcastSessionStateDebounced(session.id);
|
||
},
|
||
|
||
// Ralph tracking events
|
||
ralphLoopUpdate: (state: RalphTrackerState) => {
|
||
this.broadcast('session:ralphLoopUpdate', { sessionId: session.id, state });
|
||
// Persist Ralph state
|
||
this.store.updateRalphState(session.id, { loop: state });
|
||
},
|
||
|
||
ralphTodoUpdate: (todos: RalphTodoItem[]) => {
|
||
this.broadcast('session:ralphTodoUpdate', { sessionId: session.id, todos });
|
||
// Persist Ralph state
|
||
this.store.updateRalphState(session.id, { todos });
|
||
},
|
||
|
||
ralphCompletionDetected: (phrase: string) => {
|
||
this.broadcast('session:ralphCompletionDetected', { sessionId: session.id, phrase });
|
||
this.sendPushNotifications('session:ralphCompletionDetected', {
|
||
sessionId: session.id,
|
||
sessionName: session.name,
|
||
phrase,
|
||
});
|
||
// Track in run summary
|
||
const tracker = this.runSummaryTrackers.get(session.id);
|
||
if (tracker) tracker.recordRalphCompletion(phrase);
|
||
},
|
||
|
||
// RALPH_STATUS block events
|
||
ralphStatusBlockDetected: (block: import('../types.js').RalphStatusBlock) => {
|
||
this.broadcast('session:ralphStatusUpdate', { sessionId: session.id, block });
|
||
// Track in run summary
|
||
const tracker = this.runSummaryTrackers.get(session.id);
|
||
if (tracker) {
|
||
tracker.addEvent(
|
||
block.status === 'BLOCKED' ? 'warning' : 'idle_detected',
|
||
block.status === 'BLOCKED' ? 'warning' : 'info',
|
||
`Ralph Status: ${block.status}`,
|
||
`Tasks: ${block.tasksCompletedThisLoop}, Files: ${block.filesModified}, Tests: ${block.testsStatus}`
|
||
);
|
||
}
|
||
},
|
||
|
||
ralphCircuitBreakerUpdate: (status: import('../types.js').CircuitBreakerStatus) => {
|
||
this.broadcast('session:circuitBreakerUpdate', { sessionId: session.id, status });
|
||
// Track state changes in run summary
|
||
const tracker = this.runSummaryTrackers.get(session.id);
|
||
if (tracker && status.state === 'OPEN') {
|
||
tracker.addEvent('warning', 'warning', 'Circuit Breaker Opened', status.reason);
|
||
}
|
||
},
|
||
|
||
ralphExitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => {
|
||
this.broadcast('session:exitGateMet', { sessionId: session.id, ...data });
|
||
// Track in run summary
|
||
const tracker = this.runSummaryTrackers.get(session.id);
|
||
if (tracker) {
|
||
tracker.addEvent(
|
||
'ralph_completion',
|
||
'success',
|
||
'Exit Gate Met',
|
||
`Indicators: ${data.completionIndicators}, EXIT_SIGNAL: ${data.exitSignal}`
|
||
);
|
||
}
|
||
},
|
||
|
||
// Bash tool tracking events (for clickable file paths)
|
||
bashToolStart: (tool: ActiveBashTool) => {
|
||
this.broadcast('session:bashToolStart', { sessionId: session.id, tool });
|
||
},
|
||
|
||
bashToolEnd: (tool: ActiveBashTool) => {
|
||
this.broadcast('session:bashToolEnd', { sessionId: session.id, tool });
|
||
},
|
||
|
||
bashToolsUpdate: (tools: ActiveBashTool[]) => {
|
||
this.broadcast('session:bashToolsUpdate', { sessionId: session.id, tools });
|
||
},
|
||
};
|
||
|
||
// Store listener refs for cleanup
|
||
this.sessionListenerRefs.set(session.id, listeners);
|
||
|
||
// Attach all listeners to the session
|
||
session.on('terminal', listeners.terminal);
|
||
session.on('clearTerminal', listeners.clearTerminal);
|
||
session.on('needsRefresh', listeners.needsRefresh);
|
||
session.on('message', listeners.message);
|
||
session.on('error', listeners.error);
|
||
session.on('completion', listeners.completion);
|
||
session.on('exit', listeners.exit);
|
||
session.on('working', listeners.working);
|
||
session.on('idle', listeners.idle);
|
||
session.on('taskCreated', listeners.taskCreated);
|
||
session.on('taskUpdated', listeners.taskUpdated);
|
||
session.on('taskCompleted', listeners.taskCompleted);
|
||
session.on('taskFailed', listeners.taskFailed);
|
||
session.on('autoClear', listeners.autoClear);
|
||
session.on('autoCompact', listeners.autoCompact);
|
||
session.on('cliInfoUpdated', listeners.cliInfoUpdated);
|
||
session.on('ralphLoopUpdate', listeners.ralphLoopUpdate);
|
||
session.on('ralphTodoUpdate', listeners.ralphTodoUpdate);
|
||
session.on('ralphCompletionDetected', listeners.ralphCompletionDetected);
|
||
session.on('ralphStatusBlockDetected', listeners.ralphStatusBlockDetected);
|
||
session.on('ralphCircuitBreakerUpdate', listeners.ralphCircuitBreakerUpdate);
|
||
session.on('ralphExitGateMet', listeners.ralphExitGateMet);
|
||
session.on('bashToolStart', listeners.bashToolStart);
|
||
session.on('bashToolEnd', listeners.bashToolEnd);
|
||
session.on('bashToolsUpdate', listeners.bashToolsUpdate);
|
||
}
|
||
|
||
private setupRespawnListeners(sessionId: string, controller: RespawnController): void {
|
||
// Wire team watcher for team-aware idle detection
|
||
controller.setTeamWatcher(this.teamWatcher);
|
||
|
||
// Helper to get tracker lazily (may not exist at setup time for restored sessions)
|
||
const getTracker = () => this.runSummaryTrackers.get(sessionId);
|
||
|
||
controller.on('stateChanged', (state: RespawnState, prevState: RespawnState) => {
|
||
this.broadcast('respawn:stateChanged', { sessionId, state, prevState });
|
||
// Track in run summary (lazy lookup since tracker may be created after controller)
|
||
const tracker = getTracker();
|
||
if (tracker) tracker.recordStateChange(state, `${prevState} → ${state}`);
|
||
});
|
||
|
||
controller.on('respawnCycleStarted', (cycleNumber: number) => {
|
||
this.broadcast('respawn:cycleStarted', { sessionId, cycleNumber });
|
||
});
|
||
|
||
controller.on('respawnCycleCompleted', (cycleNumber: number) => {
|
||
this.broadcast('respawn:cycleCompleted', { sessionId, cycleNumber });
|
||
});
|
||
|
||
controller.on('respawnBlocked', (data: { reason: string; details: string }) => {
|
||
this.broadcast('respawn:blocked', { sessionId, reason: data.reason, details: data.details });
|
||
const sessionForPush = this.sessions.get(sessionId);
|
||
this.sendPushNotifications('respawn:blocked', {
|
||
sessionId,
|
||
sessionName: sessionForPush?.name ?? sessionId.slice(0, 8),
|
||
reason: data.reason,
|
||
});
|
||
// Track in run summary (lazy lookup)
|
||
const tracker = getTracker();
|
||
if (tracker) tracker.recordWarning(`Respawn blocked: ${data.reason}`, data.details);
|
||
});
|
||
|
||
controller.on('stepSent', (step: string, input: string) => {
|
||
this.broadcast('respawn:stepSent', { sessionId, step, input });
|
||
});
|
||
|
||
controller.on('stepCompleted', (step: string) => {
|
||
this.broadcast('respawn:stepCompleted', { sessionId, step });
|
||
});
|
||
|
||
controller.on('detectionUpdate', (detection: unknown) => {
|
||
this.broadcast('respawn:detectionUpdate', { sessionId, detection });
|
||
});
|
||
|
||
controller.on('autoAcceptSent', () => {
|
||
this.broadcast('respawn:autoAcceptSent', { sessionId });
|
||
});
|
||
|
||
controller.on('aiCheckStarted', () => {
|
||
this.broadcast('respawn:aiCheckStarted', { sessionId });
|
||
});
|
||
|
||
controller.on('aiCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => {
|
||
this.broadcast('respawn:aiCheckCompleted', {
|
||
sessionId,
|
||
verdict: result.verdict,
|
||
reasoning: result.reasoning,
|
||
durationMs: result.durationMs,
|
||
});
|
||
// Track in run summary (lazy lookup)
|
||
const tracker = getTracker();
|
||
if (tracker) tracker.recordAiCheckResult(result.verdict);
|
||
});
|
||
|
||
controller.on('aiCheckFailed', (error: string) => {
|
||
this.broadcast('respawn:aiCheckFailed', { sessionId, error });
|
||
// Track in run summary (lazy lookup)
|
||
const tracker = getTracker();
|
||
if (tracker) tracker.recordError('AI check failed', error);
|
||
});
|
||
|
||
controller.on('aiCheckCooldown', (active: boolean, endsAt: number | null) => {
|
||
this.broadcast('respawn:aiCheckCooldown', { sessionId, active, endsAt });
|
||
});
|
||
|
||
controller.on('planCheckStarted', () => {
|
||
this.broadcast('respawn:planCheckStarted', { sessionId });
|
||
});
|
||
|
||
controller.on('planCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => {
|
||
this.broadcast('respawn:planCheckCompleted', {
|
||
sessionId,
|
||
verdict: result.verdict,
|
||
reasoning: result.reasoning,
|
||
durationMs: result.durationMs,
|
||
});
|
||
});
|
||
|
||
controller.on('planCheckFailed', (error: string) => {
|
||
this.broadcast('respawn:planCheckFailed', { sessionId, error });
|
||
});
|
||
|
||
// Timer tracking events for UI countdown display
|
||
controller.on('timerStarted', (timer) => {
|
||
this.broadcast('respawn:timerStarted', { sessionId, timer });
|
||
});
|
||
|
||
controller.on('timerCancelled', (timerName, reason) => {
|
||
this.broadcast('respawn:timerCancelled', { sessionId, timerName, reason });
|
||
});
|
||
|
||
controller.on('timerCompleted', (timerName) => {
|
||
this.broadcast('respawn:timerCompleted', { sessionId, timerName });
|
||
});
|
||
|
||
controller.on('actionLog', (action) => {
|
||
this.broadcast('respawn:actionLog', { sessionId, action });
|
||
});
|
||
|
||
controller.on('log', (message: string) => {
|
||
this.broadcast('respawn:log', { sessionId, message });
|
||
});
|
||
|
||
controller.on('error', (error: Error) => {
|
||
this.broadcast('respawn:error', { sessionId, error: error.message });
|
||
// Track in run summary (lazy lookup)
|
||
const tracker = getTracker();
|
||
if (tracker) tracker.recordError('Respawn error', error.message);
|
||
});
|
||
}
|
||
|
||
private setupTimedRespawn(sessionId: string, durationMinutes: number): void {
|
||
// Clear existing timer if any
|
||
const existing = this.respawnTimers.get(sessionId);
|
||
if (existing) {
|
||
clearTimeout(existing.timer);
|
||
}
|
||
|
||
const now = Date.now();
|
||
const endAt = now + durationMinutes * 60 * 1000;
|
||
|
||
const timer = setTimeout(
|
||
() => {
|
||
// Stop respawn when time is up
|
||
const controller = this.respawnControllers.get(sessionId);
|
||
if (controller) {
|
||
controller.stop();
|
||
controller.removeAllListeners();
|
||
this.respawnControllers.delete(sessionId);
|
||
this.broadcast('respawn:stopped', { sessionId, reason: 'duration_expired' });
|
||
}
|
||
this.respawnTimers.delete(sessionId);
|
||
// Update persisted state (respawn no longer active)
|
||
const session = this.sessions.get(sessionId);
|
||
if (session) {
|
||
this.persistSessionState(session);
|
||
}
|
||
},
|
||
durationMinutes * 60 * 1000
|
||
);
|
||
|
||
this.respawnTimers.set(sessionId, { timer, endAt, startedAt: now });
|
||
this.broadcast('respawn:timerStarted', { sessionId, durationMinutes, endAt, startedAt: now });
|
||
}
|
||
|
||
/**
|
||
* Restore a RespawnController from persisted configuration.
|
||
* Creates the controller, sets up listeners, but does NOT start it.
|
||
*
|
||
* @param session - The session to attach the controller to
|
||
* @param config - The persisted respawn configuration
|
||
* @param source - Source of the config for logging (e.g., 'state.json' or 'mux-sessions.json')
|
||
*/
|
||
private restoreRespawnController(session: Session, config: PersistedRespawnConfig, source: string): void {
|
||
const controller = new RespawnController(session, {
|
||
idleTimeoutMs: config.idleTimeoutMs,
|
||
updatePrompt: config.updatePrompt,
|
||
interStepDelayMs: config.interStepDelayMs,
|
||
enabled: true,
|
||
sendClear: config.sendClear,
|
||
sendInit: config.sendInit,
|
||
kickstartPrompt: config.kickstartPrompt,
|
||
completionConfirmMs: config.completionConfirmMs,
|
||
noOutputTimeoutMs: config.noOutputTimeoutMs,
|
||
autoAcceptPrompts: config.autoAcceptPrompts,
|
||
autoAcceptDelayMs: config.autoAcceptDelayMs,
|
||
aiIdleCheckEnabled: config.aiIdleCheckEnabled,
|
||
aiIdleCheckModel: config.aiIdleCheckModel,
|
||
aiIdleCheckMaxContext: config.aiIdleCheckMaxContext,
|
||
aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs,
|
||
aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs,
|
||
aiPlanCheckEnabled: config.aiPlanCheckEnabled,
|
||
aiPlanCheckModel: config.aiPlanCheckModel,
|
||
aiPlanCheckMaxContext: config.aiPlanCheckMaxContext,
|
||
aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs,
|
||
aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs,
|
||
});
|
||
|
||
this.respawnControllers.set(session.id, controller);
|
||
this.setupRespawnListeners(session.id, controller);
|
||
|
||
// Calculate delay: wait until 2 minutes after server start before starting respawn
|
||
// This prevents false idle detection immediately after a server restart/rebuild
|
||
const timeSinceStart = Date.now() - this.serverStartTime;
|
||
const delayMs = Math.max(0, WebServer.RESPAWN_RESTORE_GRACE_PERIOD_MS - timeSinceStart);
|
||
|
||
if (delayMs > 0) {
|
||
console.log(
|
||
`[Server] Restored respawn controller for session ${session.id} from ${source} (will start in ${Math.ceil(delayMs / 1000)}s)`
|
||
);
|
||
const timer = setTimeout(() => {
|
||
this.pendingRespawnStarts.delete(session.id);
|
||
// Verify session still exists (may have been deleted during grace period)
|
||
if (!this.sessions.has(session.id)) {
|
||
console.log(`[Server] Skipping restored respawn start - session ${session.id} no longer exists`);
|
||
return;
|
||
}
|
||
// Double-check controller still exists and is stopped
|
||
const ctrl = this.respawnControllers.get(session.id);
|
||
if (ctrl && ctrl.state === 'stopped') {
|
||
ctrl.start();
|
||
this.broadcast('respawn:started', { sessionId: session.id });
|
||
console.log(`[Server] Restored respawn controller started for session ${session.id}`);
|
||
}
|
||
}, delayMs);
|
||
this.pendingRespawnStarts.set(session.id, timer);
|
||
} else {
|
||
// Grace period has passed, start immediately
|
||
controller.start();
|
||
console.log(
|
||
`[Server] Restored respawn controller for session ${session.id} from ${source} (started immediately)`
|
||
);
|
||
}
|
||
|
||
if (config.durationMinutes && config.durationMinutes > 0) {
|
||
this.setupTimedRespawn(session.id, config.durationMinutes);
|
||
}
|
||
}
|
||
|
||
// Helper to get custom CLAUDE.md template path from settings
|
||
private async getDefaultClaudeMdPath(): Promise<string | undefined> {
|
||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
||
|
||
try {
|
||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||
const settings = JSON.parse(content);
|
||
if (settings.defaultClaudeMdPath) {
|
||
return settings.defaultClaudeMdPath;
|
||
}
|
||
} catch (err) {
|
||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||
console.error('Failed to read settings:', err);
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
// Read ~/.codeman/settings.json once and return the parsed object.
|
||
// Cached for 2s to avoid redundant reads during session creation bursts.
|
||
private _settingsCache: { data: Record<string, unknown>; ts: number } | null = null;
|
||
private async readSettings(): Promise<Record<string, unknown>> {
|
||
const now = Date.now();
|
||
if (this._settingsCache && now - this._settingsCache.ts < 2000) {
|
||
return this._settingsCache.data;
|
||
}
|
||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
||
try {
|
||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||
const data = JSON.parse(content) as Record<string, unknown>;
|
||
this._settingsCache = { data, ts: now };
|
||
return data;
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
// Helper to get global Nice priority config from settings
|
||
private async getGlobalNiceConfig(): Promise<NiceConfig | undefined> {
|
||
const settings = await this.readSettings();
|
||
const nice = settings.nice as { enabled?: boolean; niceValue?: number } | undefined;
|
||
if (nice && nice.enabled) {
|
||
return {
|
||
enabled: nice.enabled ?? false,
|
||
niceValue: nice.niceValue ?? DEFAULT_NICE_CONFIG.niceValue,
|
||
};
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
// Helper to get Claude CLI startup mode from settings
|
||
private async getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }> {
|
||
const settings = await this.readSettings();
|
||
const claudeMode = settings.claudeMode as string | undefined;
|
||
const allowedTools = settings.allowedTools as string | undefined;
|
||
// Only return valid modes
|
||
if (claudeMode === 'dangerously-skip-permissions' || claudeMode === 'normal' || claudeMode === 'allowedTools') {
|
||
return { claudeMode, allowedTools };
|
||
}
|
||
return {};
|
||
}
|
||
|
||
// Helper to get model configuration from settings
|
||
private async getModelConfig(): Promise<{
|
||
defaultModel?: string;
|
||
agentTypeOverrides?: Record<string, string>;
|
||
} | null> {
|
||
const settings = await this.readSettings();
|
||
return (
|
||
(settings.modelConfig as {
|
||
defaultModel?: string;
|
||
agentTypeOverrides?: Record<string, string>;
|
||
}) || null
|
||
);
|
||
}
|
||
|
||
private async startScheduledRun(prompt: string, workingDir: string, durationMinutes: number): Promise<ScheduledRun> {
|
||
const id = uuidv4();
|
||
const now = Date.now();
|
||
|
||
const run: ScheduledRun = {
|
||
id,
|
||
prompt,
|
||
workingDir,
|
||
durationMinutes,
|
||
startedAt: now,
|
||
endAt: now + durationMinutes * 60 * 1000,
|
||
status: 'running',
|
||
sessionId: null,
|
||
completedTasks: 0,
|
||
totalCost: 0,
|
||
logs: [`[${new Date().toISOString()}] Scheduled run started`],
|
||
};
|
||
|
||
this.scheduledRuns.set(id, run);
|
||
this.broadcast('scheduled:created', run);
|
||
|
||
// Start the run loop (fire-and-forget with error handling)
|
||
this.runScheduledLoop(id).catch((err) => {
|
||
console.error(`[WebServer] Scheduled run ${id} failed:`, err);
|
||
const failedRun = this.scheduledRuns.get(id);
|
||
if (failedRun && failedRun.status === 'running') {
|
||
failedRun.status = 'stopped';
|
||
failedRun.logs.push(`[${new Date().toISOString()}] Error: ${err instanceof Error ? err.message : String(err)}`);
|
||
this.broadcast('scheduled:stopped', { id, reason: 'error' });
|
||
}
|
||
});
|
||
|
||
return run;
|
||
}
|
||
|
||
private async runScheduledLoop(runId: string): Promise<void> {
|
||
const run = this.scheduledRuns.get(runId);
|
||
if (!run || run.status !== 'running') return;
|
||
|
||
const addLog = (msg: string) => {
|
||
run.logs.push(`[${new Date().toISOString()}] ${msg}`);
|
||
this.broadcast('scheduled:log', { id: runId, log: run.logs[run.logs.length - 1] });
|
||
};
|
||
|
||
while (Date.now() < run.endAt && run.status === 'running') {
|
||
// Check session limit before creating new session
|
||
if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) {
|
||
addLog(`Waiting: maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`);
|
||
await new Promise((r) => setTimeout(r, SESSION_LIMIT_WAIT_MS));
|
||
continue;
|
||
}
|
||
|
||
let session: Session | null = null;
|
||
try {
|
||
// Create a session for this iteration
|
||
session = new Session({ workingDir: run.workingDir });
|
||
this.sessions.set(session.id, session);
|
||
this.store.incrementSessionsCreated();
|
||
this.persistSessionState(session);
|
||
await this.setupSessionListeners(session);
|
||
run.sessionId = session.id;
|
||
|
||
addLog(`Starting task iteration with session ${session.id.slice(0, 8)}`);
|
||
this.broadcast('scheduled:updated', run);
|
||
|
||
// Run the prompt
|
||
const timeRemaining = Math.round((run.endAt - Date.now()) / 60000);
|
||
const enhancedPrompt = `${run.prompt}\n\nNote: You have approximately ${timeRemaining} minutes remaining in this scheduled run. Work efficiently.`;
|
||
|
||
const result = await session.runPrompt(enhancedPrompt);
|
||
run.completedTasks++;
|
||
run.totalCost += result.cost;
|
||
|
||
addLog(`Task completed. Cost: $${result.cost.toFixed(4)}. Total tasks: ${run.completedTasks}`);
|
||
this.broadcast('scheduled:updated', run);
|
||
|
||
// Clean up the session after iteration to prevent memory leaks
|
||
await this.cleanupSession(session.id, true, 'scheduled_run');
|
||
run.sessionId = null;
|
||
|
||
// Small pause between iterations
|
||
await new Promise((r) => setTimeout(r, ITERATION_PAUSE_MS));
|
||
} catch (err) {
|
||
addLog(`Error: ${getErrorMessage(err)}`);
|
||
this.broadcast('scheduled:updated', run);
|
||
|
||
// Clean up the session on error too
|
||
if (session) {
|
||
try {
|
||
await this.cleanupSession(session.id, true, 'scheduled_run_error');
|
||
} catch {
|
||
// Ignore cleanup errors
|
||
}
|
||
run.sessionId = null;
|
||
}
|
||
|
||
// Continue despite errors
|
||
await new Promise((r) => setTimeout(r, SESSION_LIMIT_WAIT_MS));
|
||
}
|
||
}
|
||
|
||
if (run.status === 'running') {
|
||
run.status = 'completed';
|
||
addLog(`Scheduled run completed. Total tasks: ${run.completedTasks}, Total cost: $${run.totalCost.toFixed(4)}`);
|
||
}
|
||
|
||
this.broadcast('scheduled:completed', run);
|
||
}
|
||
|
||
private async stopScheduledRun(id: string): Promise<void> {
|
||
const run = this.scheduledRuns.get(id);
|
||
if (!run) return;
|
||
|
||
run.status = 'stopped';
|
||
run.logs.push(`[${new Date().toISOString()}] Run stopped by user`);
|
||
|
||
// Use cleanupSession for proper resource cleanup (listeners, respawn, etc.)
|
||
if (run.sessionId && this.sessions.has(run.sessionId)) {
|
||
await this.cleanupSession(run.sessionId, true, 'scheduled_run_stopped');
|
||
run.sessionId = null;
|
||
}
|
||
|
||
this.broadcast('scheduled:stopped', run);
|
||
}
|
||
|
||
/**
|
||
* Get session state with respawn controller info included.
|
||
* Use this for session:updated broadcasts to preserve respawn state on the frontend.
|
||
*/
|
||
private getSessionStateWithRespawn(session: Session) {
|
||
const controller = this.respawnControllers.get(session.id);
|
||
return {
|
||
...session.toLightDetailedState(),
|
||
respawnEnabled: controller?.getConfig()?.enabled ?? false,
|
||
respawnConfig: controller?.getConfig() ?? null,
|
||
respawn: controller?.getStatus() ?? null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Get lightweight session state for SSE init - excludes full terminal buffers
|
||
* to prevent browser freezes on SSE reconnect. Full buffers are fetched
|
||
* on-demand when switching tabs via /api/sessions/:id/buffer
|
||
*/
|
||
private getLightSessionsState() {
|
||
const now = Date.now();
|
||
if (this.cachedSessionsList && now - this.cachedSessionsList.timestamp < SESSIONS_LIST_CACHE_TTL) {
|
||
return this.cachedSessionsList.data;
|
||
}
|
||
// getSessionStateWithRespawn already uses toLightDetailedState() which
|
||
// excludes terminalBuffer and textOutput — no extra stripping needed
|
||
const data = Array.from(this.sessions.values()).map((s) => this.getSessionStateWithRespawn(s));
|
||
this.cachedSessionsList = { data, timestamp: now };
|
||
return data;
|
||
}
|
||
|
||
// Clean up old completed scheduled runs
|
||
private cleanupScheduledRuns(): void {
|
||
const now = Date.now();
|
||
const toDelete: string[] = [];
|
||
|
||
for (const [id, run] of this.scheduledRuns) {
|
||
// Only clean up completed, failed, or stopped runs
|
||
if (run.status !== 'running') {
|
||
const age = now - (run.endAt || run.startedAt);
|
||
if (age > SCHEDULED_RUN_MAX_AGE) {
|
||
toDelete.push(id);
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const id of toDelete) {
|
||
this.scheduledRuns.delete(id);
|
||
this.broadcast('scheduled:deleted', { id });
|
||
}
|
||
|
||
if (toDelete.length > 0) {
|
||
console.log(`[Server] Cleaned up ${toDelete.length} old scheduled run(s)`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cleans up stale sessions from state file that don't have active sessions.
|
||
* Called on startup and can be called via API endpoint.
|
||
* @returns Number of sessions cleaned up
|
||
*/
|
||
private cleanupStaleSessions(): number {
|
||
const activeSessionIds = new Set(this.sessions.keys());
|
||
const result = this.store.cleanupStaleSessions(activeSessionIds);
|
||
const lifecycleLog = getLifecycleLog();
|
||
for (const s of result.cleaned) {
|
||
lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name });
|
||
}
|
||
return result.count;
|
||
}
|
||
|
||
/**
|
||
* Get lightweight state for SSE init - excludes full terminal buffers
|
||
* to prevent browser freezes. Terminal buffers are fetched on-demand.
|
||
*/
|
||
private getLightState() {
|
||
const now = Date.now();
|
||
if (this.cachedLightState && now - this.cachedLightState.timestamp < WebServer.LIGHT_STATE_CACHE_TTL_MS) {
|
||
return this.cachedLightState.data;
|
||
}
|
||
|
||
const respawnStatus: Record<string, ReturnType<RespawnController['getStatus']>> = {};
|
||
for (const [sessionId, controller] of this.respawnControllers) {
|
||
respawnStatus[sessionId] = controller.getStatus();
|
||
}
|
||
|
||
const activeSessionTokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
|
||
for (const [sessionId, session] of this.sessions) {
|
||
activeSessionTokens[sessionId] = {
|
||
inputTokens: session.inputTokens,
|
||
outputTokens: session.outputTokens,
|
||
totalCost: session.totalCost,
|
||
};
|
||
}
|
||
|
||
const result = {
|
||
version: APP_VERSION,
|
||
sessions: this.getLightSessionsState(),
|
||
scheduledRuns: Array.from(this.scheduledRuns.values()),
|
||
respawnStatus,
|
||
globalStats: this.store.getAggregateStats(activeSessionTokens),
|
||
subagents: subagentWatcher.getRecentSubagents(15), // 15 min to avoid stale agents
|
||
timestamp: now,
|
||
};
|
||
|
||
this.cachedLightState = { data: result, timestamp: now };
|
||
return result;
|
||
}
|
||
|
||
private sendSSE(reply: FastifyReply, event: string, data: unknown): void {
|
||
try {
|
||
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||
} catch {
|
||
this.sseClients.delete(reply);
|
||
}
|
||
}
|
||
|
||
// Optimized: send pre-formatted SSE message to a client
|
||
// Returns false if client is backpressured or dead
|
||
private sendSSEPreformatted(reply: FastifyReply, message: string): void {
|
||
// Skip backpressured clients to prevent unbounded memory growth.
|
||
// Terminal data dropped here is recovered via session:needsRefresh on drain.
|
||
if (this.backpressuredClients.has(reply)) return;
|
||
|
||
try {
|
||
const ok = reply.raw.write(message);
|
||
if (!ok) {
|
||
// Buffer is full — mark as backpressured, resume on drain
|
||
this.backpressuredClients.add(reply);
|
||
reply.raw.once('drain', () => {
|
||
this.backpressuredClients.delete(reply);
|
||
// Client may have missed terminal data during backpressure.
|
||
// Tell it to reload the active session's buffer to recover.
|
||
try {
|
||
reply.raw.write(`event: session:needsRefresh\ndata: {}\n\n`);
|
||
} catch {
|
||
/* client gone */
|
||
}
|
||
});
|
||
}
|
||
} catch {
|
||
this.sseClients.delete(reply);
|
||
this.backpressuredClients.delete(reply);
|
||
}
|
||
}
|
||
|
||
private broadcast(event: string, data: unknown): void {
|
||
// Invalidate caches on state-changing broadcasts, but NOT on high-frequency
|
||
// streaming events that don't change session metadata (terminal data,
|
||
// detection updates). These fire every 16ms-2s and would make the 1s TTL
|
||
// caches permanently empty — defeating their purpose.
|
||
if (
|
||
(event.startsWith('session:') || event.startsWith('respawn:')) &&
|
||
event !== 'session:terminal' &&
|
||
event !== 'session:needsRefresh' &&
|
||
event !== 'respawn:detectionUpdate'
|
||
) {
|
||
this.cachedLightState = null;
|
||
this.cachedSessionsList = null;
|
||
}
|
||
// Performance optimization: serialize JSON once for all clients
|
||
let message: string;
|
||
try {
|
||
message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||
} catch (err) {
|
||
// Handle circular references or non-serializable values
|
||
console.error(`[Server] Failed to serialize SSE event "${event}":`, err);
|
||
return;
|
||
}
|
||
for (const client of this.sseClients) {
|
||
this.sendSSEPreformatted(client, message);
|
||
}
|
||
}
|
||
|
||
// Batch terminal data for better performance (60fps)
|
||
// Uses per-session timers with adaptive intervals to prevent thundering herd:
|
||
// each session flushes independently rather than all sessions flushing in one burst.
|
||
private batchTerminalData(sessionId: string, data: string): void {
|
||
// Skip if server is stopping
|
||
if (this._isStopping) return;
|
||
|
||
let chunks = this.terminalBatches.get(sessionId);
|
||
if (!chunks) {
|
||
chunks = [];
|
||
this.terminalBatches.set(sessionId, chunks);
|
||
}
|
||
chunks.push(data);
|
||
const prevSize = this.terminalBatchSizes.get(sessionId) ?? 0;
|
||
const totalLength = prevSize + data.length;
|
||
this.terminalBatchSizes.set(sessionId, totalLength);
|
||
|
||
// Adaptive batching: detect rapid events and extend batch window (per-session)
|
||
const now = Date.now();
|
||
const lastEvent = this.lastTerminalEventTime.get(sessionId) ?? 0;
|
||
const eventGap = now - lastEvent;
|
||
this.lastTerminalEventTime.set(sessionId, now);
|
||
|
||
// Adjust batch interval based on event frequency (per-session)
|
||
// Rapid events (<10ms gap) = 50ms batch, moderate (<20ms) = 32ms, else 16ms
|
||
let sessionInterval: number;
|
||
if (eventGap > 0 && eventGap < 10) {
|
||
sessionInterval = 50;
|
||
} else if (eventGap > 0 && eventGap < 20) {
|
||
sessionInterval = 32;
|
||
} else {
|
||
sessionInterval = TERMINAL_BATCH_INTERVAL;
|
||
}
|
||
|
||
// Flush immediately if batch is large for responsiveness
|
||
if (totalLength > BATCH_FLUSH_THRESHOLD) {
|
||
const existingTimer = this.terminalBatchTimers.get(sessionId);
|
||
if (existingTimer) {
|
||
clearTimeout(existingTimer);
|
||
this.terminalBatchTimers.delete(sessionId);
|
||
}
|
||
this.flushSessionTerminalBatch(sessionId);
|
||
return;
|
||
}
|
||
|
||
// Start per-session batch timer if not already running
|
||
// Each session flushes independently — prevents one busy session from
|
||
// forcing all sessions to flush at its rate (thundering herd)
|
||
if (!this.terminalBatchTimers.has(sessionId)) {
|
||
this.terminalBatchTimers.set(
|
||
sessionId,
|
||
setTimeout(() => {
|
||
this.terminalBatchTimers.delete(sessionId);
|
||
this.flushSessionTerminalBatch(sessionId);
|
||
}, sessionInterval)
|
||
);
|
||
}
|
||
}
|
||
|
||
/** Flush a single session's batched terminal data */
|
||
private flushSessionTerminalBatch(sessionId: string): void {
|
||
if (this._isStopping) {
|
||
this.terminalBatches.delete(sessionId);
|
||
this.terminalBatchSizes.delete(sessionId);
|
||
return;
|
||
}
|
||
const chunks = this.terminalBatches.get(sessionId);
|
||
if (chunks && chunks.length > 0) {
|
||
// Join chunks only at flush time (avoids O(n^2) string concatenation in batchTerminalData)
|
||
const data = chunks.join('');
|
||
// Wrap with DEC mode 2026 synchronized output markers
|
||
// Terminal buffers all output between markers and renders atomically,
|
||
// eliminating partial-frame flicker from Ink's full-screen redraws.
|
||
// Unsupported terminals ignore these sequences harmlessly.
|
||
const syncData = DEC_SYNC_START + data + DEC_SYNC_END;
|
||
// Fast path: build SSE message directly without JSON.stringify on wrapper object.
|
||
// Only the terminal data string needs escaping; sessionId is a UUID (safe to template).
|
||
const escapedData = JSON.stringify(syncData);
|
||
const message = `event: session:terminal\ndata: {"id":"${sessionId}","data":${escapedData}}\n\n`;
|
||
for (const client of this.sseClients) {
|
||
this.sendSSEPreformatted(client, message);
|
||
}
|
||
}
|
||
this.terminalBatches.delete(sessionId);
|
||
this.terminalBatchSizes.delete(sessionId);
|
||
}
|
||
|
||
// Batch task:updated events at 100ms - only send latest update per task
|
||
// Key is sessionId:taskId to avoid collisions when multiple tasks update concurrently
|
||
private batchTaskUpdate(sessionId: string, task: BackgroundTask): void {
|
||
// Skip if server is stopping
|
||
if (this._isStopping) return;
|
||
|
||
// Use composite key to avoid losing updates when multiple tasks update in same batch window
|
||
const key = `${sessionId}:${task.id}`;
|
||
this.taskUpdateBatches.set(key, { sessionId, task });
|
||
|
||
if (!this.taskUpdateBatchTimer) {
|
||
this.taskUpdateBatchTimer = setTimeout(() => {
|
||
this.flushTaskUpdateBatches();
|
||
this.taskUpdateBatchTimer = null;
|
||
}, TASK_UPDATE_BATCH_INTERVAL);
|
||
}
|
||
}
|
||
|
||
private flushTaskUpdateBatches(): void {
|
||
// Skip if server is stopping (timer may have been queued before stop() was called)
|
||
if (this._isStopping) {
|
||
this.taskUpdateBatches.clear();
|
||
return;
|
||
}
|
||
for (const [, { sessionId, task }] of this.taskUpdateBatches) {
|
||
this.broadcast('task:updated', { sessionId, task });
|
||
}
|
||
this.taskUpdateBatches.clear();
|
||
}
|
||
|
||
/**
|
||
* Debounce expensive session:updated broadcasts.
|
||
* Instead of calling toDetailedState() on every event, batch requests
|
||
* and only serialize once per STATE_UPDATE_DEBOUNCE_INTERVAL.
|
||
*/
|
||
private broadcastSessionStateDebounced(sessionId: string): void {
|
||
// Skip if server is stopping
|
||
if (this._isStopping) return;
|
||
|
||
this.stateUpdatePending.add(sessionId);
|
||
|
||
if (!this.stateUpdateTimer) {
|
||
this.stateUpdateTimer = setTimeout(() => {
|
||
this.flushStateUpdates();
|
||
this.stateUpdateTimer = null;
|
||
}, STATE_UPDATE_DEBOUNCE_INTERVAL);
|
||
}
|
||
}
|
||
|
||
private flushStateUpdates(): void {
|
||
// Skip if server is stopping (timer may have been queued before stop() was called)
|
||
if (this._isStopping) {
|
||
this.stateUpdatePending.clear();
|
||
return;
|
||
}
|
||
for (const sessionId of this.stateUpdatePending) {
|
||
const session = this.sessions.get(sessionId);
|
||
if (session) {
|
||
// Single expensive serialization per batch interval
|
||
this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
|
||
}
|
||
}
|
||
this.stateUpdatePending.clear();
|
||
}
|
||
|
||
// ========== Web Push ==========
|
||
|
||
/** Map SSE event names to push notification payloads */
|
||
private static readonly PUSH_EVENT_MAP: Record<
|
||
string,
|
||
{ title: string; urgency: string; actions?: Array<{ action: string; title: string }> }
|
||
> = {
|
||
'hook:permission_prompt': {
|
||
title: 'Permission Required',
|
||
urgency: 'critical',
|
||
actions: [
|
||
{ action: 'approve', title: 'Approve' },
|
||
{ action: 'deny', title: 'Deny' },
|
||
],
|
||
},
|
||
'hook:elicitation_dialog': { title: 'Question Asked', urgency: 'critical' },
|
||
'hook:idle_prompt': { title: 'Waiting for Input', urgency: 'warning' },
|
||
'hook:stop': { title: 'Response Complete', urgency: 'info' },
|
||
'session:error': { title: 'Session Error', urgency: 'critical' },
|
||
'respawn:blocked': { title: 'Respawn Blocked', urgency: 'critical' },
|
||
'session:ralphCompletionDetected': { title: 'Task Complete', urgency: 'warning' },
|
||
};
|
||
|
||
/**
|
||
* Send push notifications for a given event to all subscribed devices.
|
||
* Only events in PUSH_EVENT_MAP trigger push. Per-subscription preferences are checked.
|
||
* Expired subscriptions (410/404) are auto-removed.
|
||
*/
|
||
private sendPushNotifications(event: string, data: Record<string, unknown>): void {
|
||
const template = WebServer.PUSH_EVENT_MAP[event];
|
||
if (!template) return;
|
||
|
||
const subscriptions = this.pushStore.getAll();
|
||
if (subscriptions.length === 0) return;
|
||
|
||
const vapidKeys = this.pushStore.getVapidKeys();
|
||
webpush.setVapidDetails('mailto:codeman@localhost', vapidKeys.publicKey, vapidKeys.privateKey);
|
||
|
||
const sessionName = (data.sessionName as string) || '';
|
||
const sessionId = (data.sessionId as string) || '';
|
||
|
||
// Build body text from event data
|
||
let body = sessionName ? `[${sessionName}]` : '';
|
||
if (event === 'session:error' && data.error) {
|
||
body += body ? ' ' : '';
|
||
body += String(data.error).slice(0, 200);
|
||
} else if (event === 'respawn:blocked' && data.reason) {
|
||
body += body ? ' ' : '';
|
||
body += String(data.reason);
|
||
} else if (event === 'session:ralphCompletionDetected' && data.phrase) {
|
||
body += body ? ' ' : '';
|
||
body += String(data.phrase);
|
||
} else if (event === 'hook:permission_prompt' && data.tool_name) {
|
||
body += body ? ' ' : '';
|
||
body += `Tool: ${String(data.tool_name)}`;
|
||
}
|
||
|
||
const payload = JSON.stringify({
|
||
title: template.title,
|
||
body,
|
||
tag: `codeman-${event}-${sessionId}`,
|
||
sessionId,
|
||
urgency: template.urgency,
|
||
actions: template.actions,
|
||
});
|
||
|
||
for (const sub of subscriptions) {
|
||
// Check per-subscription preferences
|
||
if (sub.pushPreferences[event] === false) continue;
|
||
|
||
const pushSub = {
|
||
endpoint: sub.endpoint,
|
||
keys: sub.keys,
|
||
};
|
||
|
||
webpush.sendNotification(pushSub, payload).catch((err: { statusCode?: number }) => {
|
||
// Auto-remove expired/invalid subscriptions
|
||
if (err.statusCode === 410 || err.statusCode === 404) {
|
||
this.pushStore.removeByEndpoint(sub.endpoint);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clean up dead SSE clients and send keep-alive comments.
|
||
* Keep-alive prevents proxy/load-balancer timeouts on idle connections.
|
||
* Dead client cleanup prevents memory leaks from abruptly terminated connections.
|
||
*/
|
||
private cleanupDeadSSEClients(): void {
|
||
const deadClients: FastifyReply[] = [];
|
||
|
||
for (const client of this.sseClients) {
|
||
try {
|
||
// Check if the underlying socket is still writable
|
||
const socket = client.raw.socket;
|
||
if (!socket || socket.destroyed || !socket.writable) {
|
||
deadClients.push(client);
|
||
} else {
|
||
// Send SSE comment as keep-alive (comments start with ':')
|
||
client.raw.write(':keepalive\n\n');
|
||
}
|
||
} catch {
|
||
// Error accessing socket means client is dead
|
||
deadClients.push(client);
|
||
}
|
||
}
|
||
|
||
// Remove dead clients
|
||
for (const client of deadClients) {
|
||
this.sseClients.delete(client);
|
||
this.backpressuredClients.delete(client);
|
||
}
|
||
|
||
if (deadClients.length > 0) {
|
||
console.log(`[Server] Cleaned up ${deadClients.length} dead SSE client(s)`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Records token usage for long-running sessions periodically.
|
||
* Called every 5 minutes to capture usage in daily stats without waiting for session deletion.
|
||
*/
|
||
private recordPeriodicTokenUsage(): void {
|
||
for (const [sessionId, session] of this.sessions) {
|
||
const last = this.lastRecordedTokens.get(sessionId) || { input: 0, output: 0 };
|
||
const deltaInput = session.inputTokens - last.input;
|
||
const deltaOutput = session.outputTokens - last.output;
|
||
|
||
if (deltaInput > 0 || deltaOutput > 0) {
|
||
this.store.recordDailyUsage(deltaInput, deltaOutput, sessionId);
|
||
this.lastRecordedTokens.set(sessionId, {
|
||
input: session.inputTokens,
|
||
output: session.outputTokens,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
async start(): Promise<void> {
|
||
await this.setupRoutes();
|
||
|
||
const lifecycleLog = getLifecycleLog();
|
||
lifecycleLog.log({ event: 'server_started', sessionId: '*' });
|
||
await lifecycleLog.trimIfNeeded();
|
||
|
||
// Restore mux sessions BEFORE accepting connections
|
||
// This prevents race conditions where clients connect before state is ready
|
||
// CRITICAL: Skip in test mode to prevent tests from picking up user sessions
|
||
if (!this.testMode) {
|
||
await this.restoreMuxSessions();
|
||
}
|
||
|
||
// Clean up stale sessions from state file that don't have active mux sessions
|
||
this.cleanupStaleSessions();
|
||
|
||
await this.app.listen({ port: this.port, host: '0.0.0.0' });
|
||
const protocol = this.https ? 'https' : 'http';
|
||
console.log(`Codeman web interface running at ${protocol}://localhost:${this.port}`);
|
||
|
||
// Security warning: server binds to 0.0.0.0 (all interfaces) — warn if no auth configured
|
||
if (!process.env.CODEMAN_PASSWORD) {
|
||
console.warn('\n⚠ WARNING: No CODEMAN_PASSWORD set — server is accessible without authentication.');
|
||
console.warn(' Anyone on your network can access and control Claude sessions.');
|
||
console.warn(' Set CODEMAN_PASSWORD environment variable to enable auth.\n');
|
||
}
|
||
|
||
// Set API URL for child processes (MCP server, spawned sessions)
|
||
process.env.CODEMAN_API_URL = `${protocol}://localhost:${this.port}`;
|
||
|
||
// Start scheduled runs cleanup timer
|
||
this.scheduledCleanupTimer = setInterval(() => {
|
||
this.cleanupScheduledRuns();
|
||
}, SCHEDULED_CLEANUP_INTERVAL);
|
||
|
||
// Start SSE client health check timer (prevents memory leaks from dead connections)
|
||
this.sseHealthCheckTimer = setInterval(() => {
|
||
this.cleanupDeadSSEClients();
|
||
}, SSE_HEALTH_CHECK_INTERVAL);
|
||
|
||
// Start token recording timer (every 5 minutes for long-running sessions)
|
||
this.tokenRecordingTimer = setInterval(
|
||
() => {
|
||
this.recordPeriodicTokenUsage();
|
||
},
|
||
5 * 60 * 1000
|
||
);
|
||
|
||
// Start subagent watcher for Claude Code background agent visibility (if enabled)
|
||
if (await this.isSubagentTrackingEnabled()) {
|
||
subagentWatcher.start();
|
||
console.log('Subagent watcher started - monitoring ~/.claude/projects for background agent activity');
|
||
} else {
|
||
console.log('Subagent watcher disabled by user settings');
|
||
}
|
||
|
||
// Start image watcher for auto-popup of screenshots (if enabled)
|
||
if (await this.isImageWatcherEnabled()) {
|
||
imageWatcher.start();
|
||
console.log('Image watcher started - monitoring session directories for new images');
|
||
} else {
|
||
console.log('Image watcher disabled by user settings');
|
||
}
|
||
|
||
// Tunnel only starts when user clicks the toggle in the UI — never on boot.
|
||
// Reset persisted tunnelEnabled so the UI toggle reflects actual state.
|
||
if (await this.isTunnelEnabled()) {
|
||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
||
try {
|
||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||
const settings = JSON.parse(content);
|
||
settings.tunnelEnabled = false;
|
||
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
console.log('Cloudflare tunnel setting reset (tunnel only starts on explicit UI toggle)');
|
||
}
|
||
|
||
// Start team watcher for agent team awareness (always on — lightweight polling)
|
||
this.teamWatcher.start();
|
||
console.log('Team watcher started - monitoring ~/.claude/teams/ for agent team activity');
|
||
}
|
||
|
||
/**
|
||
* Check if subagent tracking is enabled in settings (default: true)
|
||
*/
|
||
private async isSubagentTrackingEnabled(): Promise<boolean> {
|
||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
||
try {
|
||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||
const settings = JSON.parse(content);
|
||
// Default to true if not explicitly set
|
||
return settings.subagentTrackingEnabled ?? true;
|
||
} catch (err) {
|
||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||
console.error('Failed to read subagent tracking setting:', err);
|
||
}
|
||
}
|
||
return true; // Default enabled
|
||
}
|
||
|
||
/**
|
||
* Check if image watcher is enabled in settings (default: false)
|
||
*/
|
||
private async isImageWatcherEnabled(): Promise<boolean> {
|
||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
||
try {
|
||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||
const settings = JSON.parse(content);
|
||
// Default to false if not explicitly set (matches UI default)
|
||
return settings.imageWatcherEnabled ?? false;
|
||
} catch (err) {
|
||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||
console.error('Failed to read image watcher setting:', err);
|
||
}
|
||
}
|
||
return false; // Default disabled (matches UI default)
|
||
}
|
||
|
||
/**
|
||
* Check if Cloudflare tunnel is enabled in settings (default: false)
|
||
*/
|
||
private async isTunnelEnabled(): Promise<boolean> {
|
||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
||
try {
|
||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||
const settings = JSON.parse(content);
|
||
return settings.tunnelEnabled ?? false;
|
||
} catch (err) {
|
||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||
console.error('Failed to read tunnel setting:', err);
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
private async restoreMuxSessions(): Promise<void> {
|
||
try {
|
||
// Reconcile mux sessions to find which ones are still alive (also discovers unknown ones)
|
||
const { alive, dead, discovered } = await this.mux.reconcileSessions();
|
||
|
||
if (discovered.length > 0) {
|
||
console.log(`[Server] Discovered ${discovered.length} unknown mux session(s)`);
|
||
}
|
||
|
||
if (alive.length > 0 || discovered.length > 0) {
|
||
console.log(`[Server] Found ${alive.length + discovered.length} alive mux session(s) from previous run`);
|
||
|
||
// For each alive mux session, create a Session object if it doesn't exist
|
||
const muxSessions = this.mux.getSessions();
|
||
for (const muxSession of muxSessions) {
|
||
if (!this.sessions.has(muxSession.sessionId)) {
|
||
// Restore session settings from state.json (single source of truth)
|
||
const savedState = this.store.getSession(muxSession.sessionId);
|
||
|
||
// Determine the correct session name (priority: savedState > muxSession > muxName)
|
||
// This ensures renamed sessions keep their name after server restart
|
||
const sessionName = savedState?.name || muxSession.name || muxSession.muxName;
|
||
|
||
// Create a session object for this mux session
|
||
const recoveryClaudeMode = await this.getClaudeModeConfig();
|
||
const session = new Session({
|
||
id: muxSession.sessionId, // Preserve the original session ID
|
||
workingDir: muxSession.workingDir,
|
||
mode: muxSession.mode,
|
||
name: sessionName,
|
||
mux: this.mux,
|
||
useMux: true,
|
||
muxSession: muxSession, // Pass the existing session so startInteractive() can attach to it
|
||
claudeMode: recoveryClaudeMode.claudeMode,
|
||
allowedTools: recoveryClaudeMode.allowedTools,
|
||
});
|
||
|
||
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
|
||
if (savedState?.name && muxSession.name !== savedState.name) {
|
||
this.mux.updateSessionName(muxSession.sessionId, savedState.name);
|
||
}
|
||
if (savedState) {
|
||
// Auto-compact
|
||
if (savedState.autoCompactEnabled !== undefined || savedState.autoCompactThreshold !== undefined) {
|
||
session.setAutoCompact(
|
||
savedState.autoCompactEnabled ?? false,
|
||
savedState.autoCompactThreshold,
|
||
savedState.autoCompactPrompt
|
||
);
|
||
}
|
||
// Auto-clear
|
||
if (savedState.autoClearEnabled !== undefined || savedState.autoClearThreshold !== undefined) {
|
||
session.setAutoClear(savedState.autoClearEnabled ?? false, savedState.autoClearThreshold);
|
||
}
|
||
// Token tracking
|
||
if (
|
||
savedState.inputTokens !== undefined ||
|
||
savedState.outputTokens !== undefined ||
|
||
savedState.totalCost !== undefined
|
||
) {
|
||
session.restoreTokens(
|
||
savedState.inputTokens ?? 0,
|
||
savedState.outputTokens ?? 0,
|
||
savedState.totalCost ?? 0
|
||
);
|
||
// Initialize lastRecordedTokens to prevent re-counting restored tokens as new daily usage
|
||
this.lastRecordedTokens.set(session.id, {
|
||
input: savedState.inputTokens ?? 0,
|
||
output: savedState.outputTokens ?? 0,
|
||
});
|
||
const totalTokens = (savedState.inputTokens ?? 0) + (savedState.outputTokens ?? 0);
|
||
if (totalTokens > 0) {
|
||
console.log(
|
||
`[Server] Restored tokens for session ${session.id}: ${totalTokens} tokens, $${(savedState.totalCost ?? 0).toFixed(4)}`
|
||
);
|
||
}
|
||
}
|
||
// Ralph / Todo tracker (not supported for opencode sessions)
|
||
if (session.mode !== 'opencode') {
|
||
if (savedState.ralphAutoEnableDisabled) {
|
||
session.ralphTracker.disableAutoEnable();
|
||
console.log(`[Server] Restored Ralph auto-enable disabled for session ${session.id}`);
|
||
} else if (savedState.ralphEnabled) {
|
||
// If Ralph was enabled and not explicitly disabled, allow re-enabling on restart
|
||
session.ralphTracker.enableAutoEnable();
|
||
}
|
||
if (savedState.ralphEnabled) {
|
||
session.ralphTracker.enable();
|
||
if (savedState.ralphCompletionPhrase) {
|
||
session.ralphTracker.startLoop(savedState.ralphCompletionPhrase);
|
||
}
|
||
console.log(
|
||
`[Server] Restored Ralph tracker for session ${session.id} (phrase: ${savedState.ralphCompletionPhrase || 'none'})`
|
||
);
|
||
}
|
||
}
|
||
// Nice priority config
|
||
if (savedState.niceEnabled !== undefined) {
|
||
session.setNice({
|
||
enabled: savedState.niceEnabled,
|
||
niceValue: savedState.niceValue,
|
||
});
|
||
}
|
||
// Flicker filter (frontend-applied but persisted)
|
||
if (savedState.flickerFilterEnabled !== undefined) {
|
||
session.flickerFilterEnabled = savedState.flickerFilterEnabled;
|
||
}
|
||
// Respawn controller (not supported for opencode sessions)
|
||
if (session.mode !== 'opencode' && savedState.respawnEnabled && savedState.respawnConfig) {
|
||
try {
|
||
this.restoreRespawnController(session, savedState.respawnConfig, 'state.json');
|
||
} catch (err) {
|
||
console.error(`[Server] Failed to restore respawn for session ${session.id}:`, err);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fallback: restore respawn from mux-sessions.json if state.json didn't have it (not supported for opencode)
|
||
if (
|
||
session.mode !== 'opencode' &&
|
||
!this.respawnControllers.has(session.id) &&
|
||
muxSession.respawnConfig?.enabled
|
||
) {
|
||
try {
|
||
this.restoreRespawnController(session, muxSession.respawnConfig, 'mux-sessions.json');
|
||
} catch (err) {
|
||
console.error(
|
||
`[Server] Failed to restore respawn from mux-sessions.json for session ${session.id}:`,
|
||
err
|
||
);
|
||
}
|
||
}
|
||
|
||
// Fallback: restore Ralph state from state-inner.json if not already set and not explicitly disabled
|
||
// Ralph tracker is not supported for opencode sessions
|
||
if (
|
||
session.mode !== 'opencode' &&
|
||
!session.ralphTracker.enabled &&
|
||
!session.ralphTracker.autoEnableDisabled
|
||
) {
|
||
const ralphState = this.store.getRalphState(muxSession.sessionId);
|
||
if (ralphState?.loop?.enabled) {
|
||
session.ralphTracker.restoreState(ralphState.loop, ralphState.todos);
|
||
console.log(`[Server] Restored Ralph state from inner store for session ${session.id}`);
|
||
}
|
||
}
|
||
|
||
// Fallback: auto-detect completion phrase from CLAUDE.md (not supported for opencode)
|
||
if (
|
||
session.mode !== 'opencode' &&
|
||
session.ralphTracker.enabled &&
|
||
!session.ralphTracker.loopState.completionPhrase
|
||
) {
|
||
const claudeMdPath = join(session.workingDir, 'CLAUDE.md');
|
||
const completionPhrase = extractCompletionPhrase(claudeMdPath);
|
||
if (completionPhrase) {
|
||
session.ralphTracker.startLoop(completionPhrase);
|
||
console.log(`[Server] Auto-detected completion phrase for session ${session.id}: ${completionPhrase}`);
|
||
}
|
||
}
|
||
|
||
this.sessions.set(session.id, session);
|
||
await this.setupSessionListeners(session);
|
||
this.persistSessionState(session);
|
||
|
||
// Mark it as restored (not started yet - user needs to attach)
|
||
getLifecycleLog().log({
|
||
event: 'recovered',
|
||
sessionId: session.id,
|
||
name: session.name,
|
||
});
|
||
console.log(`[Server] Restored session ${session.id} from mux ${muxSession.muxName}`);
|
||
}
|
||
}
|
||
|
||
// Start stats collection for mux sessions
|
||
this.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
|
||
}
|
||
|
||
// Start mouse mode sync (tmux only) — toggles mouse on/off based on pane count.
|
||
// Mouse off = native xterm.js selection; mouse on = tmux pane clicking (split layouts).
|
||
// Always start, even with no sessions — new sessions may be created later.
|
||
if ('startMouseModeSync' in this.mux) {
|
||
(this.mux as { startMouseModeSync: (ms?: number) => void }).startMouseModeSync();
|
||
}
|
||
|
||
if (dead.length > 0) {
|
||
console.log(`[Server] Cleaned up ${dead.length} dead mux session(s)`);
|
||
}
|
||
} catch (err) {
|
||
console.error('[Server] Failed to restore mux sessions:', err);
|
||
}
|
||
}
|
||
|
||
async stop(): Promise<void> {
|
||
getLifecycleLog().log({ event: 'server_stopped', sessionId: '*' });
|
||
// Set stopping flag to prevent new timer creation during shutdown
|
||
this._isStopping = true;
|
||
|
||
// Clear SSE health check timer
|
||
if (this.sseHealthCheckTimer) {
|
||
clearInterval(this.sseHealthCheckTimer);
|
||
this.sseHealthCheckTimer = null;
|
||
}
|
||
|
||
// Gracefully close all SSE connections before clearing
|
||
for (const client of this.sseClients) {
|
||
try {
|
||
// Send a final event to notify clients of shutdown
|
||
this.sendSSE(client, 'server:shutdown', { reason: 'Server stopping' });
|
||
client.raw.end();
|
||
} catch {
|
||
// Client may already be disconnected
|
||
}
|
||
}
|
||
this.sseClients.clear();
|
||
this.backpressuredClients.clear();
|
||
|
||
// Clear per-session batch timers
|
||
for (const timer of this.terminalBatchTimers.values()) {
|
||
clearTimeout(timer);
|
||
}
|
||
this.terminalBatchTimers.clear();
|
||
this.terminalBatches.clear();
|
||
this.terminalBatchSizes.clear();
|
||
|
||
if (this.taskUpdateBatchTimer) {
|
||
clearTimeout(this.taskUpdateBatchTimer);
|
||
this.taskUpdateBatchTimer = null;
|
||
}
|
||
this.taskUpdateBatches.clear();
|
||
|
||
if (this.stateUpdateTimer) {
|
||
clearTimeout(this.stateUpdateTimer);
|
||
this.stateUpdateTimer = null;
|
||
}
|
||
this.stateUpdatePending.clear();
|
||
|
||
// Clear token recording timer
|
||
if (this.tokenRecordingTimer) {
|
||
clearInterval(this.tokenRecordingTimer);
|
||
this.tokenRecordingTimer = null;
|
||
}
|
||
this.lastRecordedTokens.clear();
|
||
|
||
// Clear scheduled cleanup timer
|
||
if (this.scheduledCleanupTimer) {
|
||
clearInterval(this.scheduledCleanupTimer);
|
||
this.scheduledCleanupTimer = null;
|
||
}
|
||
|
||
// Stop multiplexer and flush pending saves
|
||
this.mux.destroy();
|
||
|
||
// Flush any pending persist-debounce timers and persist dirty sessions
|
||
for (const [sessionId, timer] of this.persistDebounceTimers) {
|
||
clearTimeout(timer);
|
||
const session = this.sessions.get(sessionId);
|
||
if (session) {
|
||
this._persistSessionStateNow(session);
|
||
}
|
||
}
|
||
this.persistDebounceTimers.clear();
|
||
|
||
// Clear cached state
|
||
this.cachedLightState = null;
|
||
this.cachedSessionsList = null;
|
||
|
||
// Clear all pending respawn start timers (from restoration grace period)
|
||
for (const timer of this.pendingRespawnStarts.values()) {
|
||
clearTimeout(timer);
|
||
}
|
||
this.pendingRespawnStarts.clear();
|
||
|
||
// Stop all respawn controllers and remove listeners
|
||
for (const controller of this.respawnControllers.values()) {
|
||
controller.stop();
|
||
controller.removeAllListeners();
|
||
}
|
||
this.respawnControllers.clear();
|
||
|
||
// Stop all scheduled runs first (they have their own session cleanup)
|
||
await Promise.allSettled(Array.from(this.scheduledRuns.keys()).map((id) => this.stopScheduledRun(id)));
|
||
|
||
// On server shutdown, DO NOT call cleanupSession — it tears down session state,
|
||
// removes listeners, kills PTY processes, and broadcasts session:deleted.
|
||
// Instead, just persist current state and let the PTY die naturally when process exits.
|
||
// The tmux sessions survive independently, and restoreMuxSessions() will find them on restart.
|
||
for (const [sessionId, session] of this.sessions) {
|
||
// Persist final state so recovery has up-to-date tokens, ralph state, etc.
|
||
this._persistSessionStateNow(session);
|
||
// Remove listeners to avoid spurious events during teardown
|
||
const listeners = this.sessionListenerRefs.get(sessionId);
|
||
if (listeners) {
|
||
session.off('terminal', listeners.terminal);
|
||
session.off('clearTerminal', listeners.clearTerminal);
|
||
session.off('needsRefresh', listeners.needsRefresh);
|
||
session.off('message', listeners.message);
|
||
session.off('error', listeners.error);
|
||
session.off('completion', listeners.completion);
|
||
session.off('exit', listeners.exit);
|
||
session.off('working', listeners.working);
|
||
session.off('idle', listeners.idle);
|
||
session.off('taskCreated', listeners.taskCreated);
|
||
session.off('taskUpdated', listeners.taskUpdated);
|
||
session.off('taskCompleted', listeners.taskCompleted);
|
||
session.off('taskFailed', listeners.taskFailed);
|
||
session.off('autoClear', listeners.autoClear);
|
||
session.off('autoCompact', listeners.autoCompact);
|
||
session.off('cliInfoUpdated', listeners.cliInfoUpdated);
|
||
session.off('ralphLoopUpdate', listeners.ralphLoopUpdate);
|
||
session.off('ralphTodoUpdate', listeners.ralphTodoUpdate);
|
||
session.off('ralphCompletionDetected', listeners.ralphCompletionDetected);
|
||
session.off('ralphStatusBlockDetected', listeners.ralphStatusBlockDetected);
|
||
session.off('ralphCircuitBreakerUpdate', listeners.ralphCircuitBreakerUpdate);
|
||
session.off('ralphExitGateMet', listeners.ralphExitGateMet);
|
||
session.off('bashToolStart', listeners.bashToolStart);
|
||
session.off('bashToolEnd', listeners.bashToolEnd);
|
||
session.off('bashToolsUpdate', listeners.bashToolsUpdate);
|
||
this.sessionListenerRefs.delete(sessionId);
|
||
}
|
||
session.removeAllListeners();
|
||
// Close file streams and image watchers (these are server-side resources)
|
||
fileStreamManager.closeSessionStreams(sessionId);
|
||
imageWatcher.unwatchSession(sessionId);
|
||
}
|
||
// Don't delete sessions from the map or state.json — recovery needs them
|
||
|
||
// Flush state store to prevent data loss from debounced saves
|
||
this.store.flushAll();
|
||
|
||
// Clean up watcher listeners to prevent memory leaks
|
||
this.cleanupSubagentWatcherListeners();
|
||
this.cleanupImageWatcherListeners();
|
||
this.cleanupTeamWatcherListeners();
|
||
|
||
// Stop subagent watcher
|
||
subagentWatcher.stop();
|
||
|
||
// Stop image watcher
|
||
imageWatcher.stop();
|
||
|
||
// Stop team watcher
|
||
this.teamWatcher.stop();
|
||
|
||
// Stop tunnel
|
||
this.tunnelManager.stop();
|
||
this.tunnelManager.removeAllListeners();
|
||
|
||
// Destroy file stream manager (clears cleanup timer and kills remaining tail processes)
|
||
fileStreamManager.destroy();
|
||
|
||
// Stop all remaining tracked resources before clearing their Maps
|
||
for (const tracker of this.runSummaryTrackers.values()) {
|
||
tracker.stop();
|
||
}
|
||
for (const watcher of this.transcriptWatchers.values()) {
|
||
watcher.removeAllListeners();
|
||
watcher.stop();
|
||
}
|
||
for (const orchestrator of this.activePlanOrchestrators.values()) {
|
||
orchestrator.cancel();
|
||
}
|
||
|
||
// Clear remaining Maps that accumulate session references
|
||
for (const { timer } of this.respawnTimers.values()) {
|
||
clearTimeout(timer);
|
||
}
|
||
this.respawnTimers.clear();
|
||
this.runSummaryTrackers.clear();
|
||
this.transcriptWatchers.clear();
|
||
this.sessionListenerRefs.clear();
|
||
this.scheduledRuns.clear();
|
||
// Dispose StaleExpirationMaps (stops internal cleanup timers)
|
||
this.lastTerminalEventTime.dispose();
|
||
if (this.authSessions) {
|
||
this.authSessions.dispose();
|
||
this.authSessions = null;
|
||
}
|
||
if (this.authFailures) {
|
||
this.authFailures.dispose();
|
||
this.authFailures = null;
|
||
}
|
||
this.activePlanOrchestrators.clear();
|
||
this.cleaningUp.clear();
|
||
|
||
// Dispose push store (flush pending saves)
|
||
this.pushStore.dispose();
|
||
|
||
await this.app.close();
|
||
}
|
||
}
|
||
|
||
export async function startWebServer(
|
||
port: number = 3000,
|
||
https: boolean = false,
|
||
testMode: boolean = false
|
||
): Promise<WebServer> {
|
||
const server = new WebServer(port, https, testMode);
|
||
await server.start();
|
||
return server;
|
||
}
|