2026-01-21 08:37:35 +01:00
/**
* @fileoverview Claudeman 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
*/
2026-01-21 06:51:41 +01:00
import Fastify , { FastifyInstance , FastifyReply } from 'fastify' ;
2026-02-13 21:21:33 +01:00
import fastifyCompress from '@fastify/compress' ;
2026-01-18 05:14:34 +01:00
import fastifyStatic from '@fastify/static' ;
2026-02-19 08:49:36 +01:00
import { join , dirname , resolve , relative , isAbsolute } from 'node:path' ;
2026-01-18 05:14:34 +01:00
import { fileURLToPath } from 'node:url' ;
2026-02-18 10:23:10 +01:00
import { existsSync , statSync , mkdirSync , writeFileSync , readdirSync , readFileSync , rmSync } from 'node:fs' ;
2026-01-27 01:02:38 +01:00
import fs from 'node:fs/promises' ;
2026-02-12 14:42:58 +01:00
import { execSync } from 'node:child_process' ;
import { homedir , totalmem , freemem , loadavg , cpus } from 'node:os' ;
2026-01-18 05:14:34 +01:00
import { EventEmitter } from 'node:events' ;
2026-01-26 00:55:29 +01:00
import { Session , ClaudeMessage , type BackgroundTask , type RalphTrackerState , type RalphTodoItem , type ActiveBashTool } from '../session.js' ;
import { fileStreamManager } from '../file-stream-manager.js' ;
2026-01-18 07:31:19 +01:00
import { RespawnController , RespawnConfig , RespawnState } from '../respawn-controller.js' ;
2026-02-12 10:29:57 +01:00
import type { TerminalMultiplexer } from '../mux-interface.js' ;
import { createMultiplexer } from '../mux-factory.js' ;
2026-01-18 05:14:34 +01:00
import { getStore } from '../state-store.js' ;
2026-01-18 07:31:19 +01:00
import { generateClaudeMd } from '../templates/claude-md.js' ;
2026-01-21 22:36:45 +01:00
import { parseRalphLoopConfig , extractCompletionPhrase } from '../ralph-config.js' ;
2026-02-12 08:34:51 +01:00
import { writeHooksConfig , updateCaseEnvVars } from '../hooks-config.js' ;
2026-01-26 10:42:04 +01:00
import { subagentWatcher , type SubagentInfo , type SubagentToolCall , type SubagentProgress , type SubagentMessage , type SubagentToolResult } from '../subagent-watcher.js' ;
2026-01-28 02:31:58 +01:00
import { imageWatcher } from '../image-watcher.js' ;
2026-01-25 21:29:35 +01:00
import { TranscriptWatcher } from '../transcript-watcher.js' ;
2026-02-19 14:00:10 +01:00
import { TeamWatcher } from '../team-watcher.js' ;
2026-01-18 05:14:34 +01:00
import { v4 as uuidv4 } from 'uuid' ;
2026-01-25 04:04:24 +01:00
import { createRequire } from 'node:module' ;
2026-01-25 22:05:28 +01:00
import { RunSummaryTracker } from '../run-summary.js' ;
2026-01-27 18:16:19 +01:00
import { PlanOrchestrator , type DetailedPlanResult } from '../plan-orchestrator.js' ;
2026-02-19 18:30:14 +01:00
import { getLifecycleLog } from '../session-lifecycle-log.js' ;
2026-01-25 04:04:24 +01:00
// Load version from package.json
const require = createRequire ( import . meta . url );
const { version : APP_VERSION } = require ( '../../package.json' );
2026-01-21 14:07:04 +01:00
import {
getErrorMessage ,
2026-01-21 16:20:56 +01:00
ApiErrorCode ,
createErrorResponse ,
2026-01-21 14:07:04 +01:00
type ApiResponse ,
type SessionResponse ,
type QuickStartResponse ,
type CaseInfo ,
2026-01-21 18:30:06 +01:00
type PersistedRespawnConfig ,
2026-01-27 11:43:20 +01:00
type NiceConfig ,
2026-01-28 02:31:58 +01:00
type ImageDetectedEvent ,
2026-01-27 11:43:20 +01:00
DEFAULT_NICE_CONFIG ,
2026-01-18 21:36:03 +01:00
} from '../types.js' ;
2026-02-04 07:24:32 +01:00
import {
CreateSessionSchema ,
RunPromptSchema ,
2026-02-18 07:06:34 +01:00
SessionInputWithLimitSchema ,
2026-02-04 07:24:32 +01:00
ResizeSchema ,
CreateCaseSchema ,
QuickStartSchema ,
HookEventSchema ,
ConfigUpdateSchema ,
RespawnConfigSchema ,
2026-02-15 05:26:29 +01:00
SessionNameSchema ,
SessionColorSchema ,
RalphConfigSchema ,
FixPlanImportSchema ,
RalphPromptWriteSchema ,
AutoClearSchema ,
AutoCompactSchema ,
ImageWatcherSchema ,
FlickerFilterSchema ,
QuickRunSchema ,
ScheduledRunSchema ,
LinkCaseSchema ,
GeneratePlanSchema ,
GeneratePlanDetailedSchema ,
CancelPlanSchema ,
PlanTaskUpdateSchema ,
PlanTaskAddSchema ,
CpuLimitSchema ,
2026-02-18 07:06:34 +01:00
SettingsUpdateSchema ,
ModelConfigUpdateSchema ,
SubagentWindowStatesSchema ,
SubagentParentMapSchema ,
InteractiveRespawnSchema ,
RespawnEnableSchema ,
2026-02-04 07:24:32 +01:00
} from './schemas.js' ;
import { StaleExpirationMap } from '../utils/index.js' ;
2026-02-19 08:49:36 +01:00
import { MAX_CONCURRENT_SESSIONS } from '../config/map-limits.js' ;
2026-01-18 05:14:34 +01:00
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 [];
}
2026-01-18 21:36:03 +01:00
// Batch terminal data for performance - collect for 16ms (60fps) before sending
const TERMINAL_BATCH_INTERVAL = 16 ;
2026-01-19 06:51:25 +01:00
// Batch task:updated events for 100ms
const TASK_UPDATE_BATCH_INTERVAL = 100 ;
2026-01-25 22:05:28 +01:00
// 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)
2026-01-21 05:53:59 +01:00
// State update debounce interval (batch expensive toDetailedState() calls)
const STATE_UPDATE_DEBOUNCE_INTERVAL = 500 ;
2026-02-15 05:33:34 +01:00
// Cache TTL for getLightSessionsState() — avoids re-serializing all sessions on every SSE init / /api/sessions call
const SESSIONS_LIST_CACHE_TTL = 1000 ;
2026-01-19 06:51:25 +01:00
// 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 ;
2026-01-22 00:08:32 +01:00
// SSE client health check interval (every 30 seconds)
const SSE_HEALTH_CHECK_INTERVAL = 30 * 1000 ;
2026-01-24 04:35:58 +01:00
// 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 ;
2026-02-16 12:10:44 +01:00
// Maximum screenshot upload size (10MB)
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024 ;
// Screenshots directory
const SCREENSHOTS_DIR = join ( homedir (), '.claudeman' , 'screenshots' );
2026-01-26 14:27:21 +01:00
// 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 ;
2026-02-02 04:49:15 +01:00
// 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 ;
2026-01-24 04:35:58 +01:00
// Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation)
const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/ ;
const CTRL_L_PATTERN = /\x0c/g ;
const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/ ;
2026-01-27 00:29:38 +01:00
/**
* 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 ( ' ' );
}
2026-01-24 04:35:58 +01:00
/**
* 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.
*/
2026-02-12 20:14:01 +01:00
function sanitizeHookData ( data : Record < string , unknown > | null | undefined ) : Record < string , unknown > {
2026-01-24 04:35:58 +01:00
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' ,
2026-01-25 21:29:35 +01:00
'cwd' , 'permission_mode' , 'stop_hook_active' , 'transcript_path' ,
2026-01-24 04:35:58 +01:00
];
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 ;
}
2026-01-18 21:36:03 +01:00
2026-01-21 19:49:44 +01:00
/**
2026-01-21 22:36:45 +01:00
* Auto-configure Ralph tracker for a session.
2026-01-21 19:49:44 +01:00
*
2026-01-21 22:36:45 +01:00
* Priority order:
* 1. .claude/ralph-loop.local.md (official Ralph Wiggum plugin state)
* 2. CLAUDE.md <promise> tags (fallback)
2026-01-21 19:49:44 +01:00
*
2026-01-21 22:36:45 +01:00
* The ralph-loop.local.md file has priority because it contains
* the exact configuration from an active Ralph loop session.
2026-01-21 19:49:44 +01:00
*/
2026-01-21 22:36:45 +01:00
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 );
2026-01-21 19:49:44 +01:00
2026-01-21 22:36:45 +01:00
if ( ralphConfig && ralphConfig . completionPromise ) {
session . ralphTracker . enable ();
session . ralphTracker . startLoop (
ralphConfig . completionPromise ,
ralphConfig . maxIterations ?? undefined
);
2026-01-21 19:49:44 +01:00
2026-01-21 22:36:45 +01:00
// 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 ?? '∞' } ` );
2026-01-21 19:49:44 +01:00
}
2026-01-21 22:36:45 +01:00
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
2026-01-21 19:49:44 +01:00
const claudeMdPath = join ( workingDir , 'CLAUDE.md' );
const completionPhrase = extractCompletionPhrase ( claudeMdPath );
if ( completionPhrase ) {
2026-01-21 22:36:45 +01:00
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' , {
2026-01-21 19:49:44 +01:00
sessionId : session.id ,
2026-01-21 22:36:45 +01:00
state : session.ralphTracker.loopState ,
2026-01-21 19:49:44 +01:00
});
}
}
2026-01-24 02:12:22 +01:00
/**
* Get or generate a self-signed TLS certificate for HTTPS.
* Certs are stored in ~/.claudeman/certs/ and reused across restarts.
*/
function getOrCreateSelfSignedCert () : { key : string ; cert : string } {
const certsDir = join ( homedir (), '.claudeman' , '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 });
// 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=claudeman" ` +
`-addext "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:0.0.0.0"` ,
{ stdio : 'pipe' }
);
return {
key : readFileSync ( keyPath , 'utf-8' ),
cert : readFileSync ( certPath , 'utf-8' ),
};
}
2026-01-31 23:47:49 +01:00
/** Stored listener references for session cleanup (prevents memory leaks) */
interface SessionListenerRefs {
terminal : ( data : string ) => void ;
clearTerminal : () => 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 ;
}
2026-01-18 05:14:34 +01:00
export class WebServer extends EventEmitter {
2026-02-12 18:26:16 +01:00
/** Cached CPU count — doesn't change at runtime */
private static readonly CPU_COUNT = cpus (). length ;
2026-01-18 05:14:34 +01:00
private app : FastifyInstance ;
private sessions : Map < string , Session > = new Map ();
2026-01-18 07:31:19 +01:00
private respawnControllers : Map < string , RespawnController > = new Map ();
2026-01-19 00:38:11 +01:00
private respawnTimers : Map < string , { timer : NodeJS.Timeout ; endAt : number ; startedAt : number }> = new Map ();
2026-01-25 22:05:28 +01:00
private runSummaryTrackers : Map < string , RunSummaryTracker > = new Map ();
2026-01-25 21:29:35 +01:00
private transcriptWatchers : Map < string , TranscriptWatcher > = new Map ();
2026-01-31 23:47:49 +01:00
// Store session listener references for explicit cleanup (prevents memory leaks)
private sessionListenerRefs : Map < string , SessionListenerRefs > = new Map ();
2026-01-18 05:14:34 +01:00
private scheduledRuns : Map < string , ScheduledRun > = new Map ();
private sseClients : Set < FastifyReply > = new Set ();
2026-02-12 18:26:16 +01:00
/** Clients with backpressure — skip writes until 'drain' fires */
private backpressuredClients : Set < FastifyReply > = new Set ();
2026-01-18 05:14:34 +01:00
private store = getStore ();
private port : number ;
2026-01-24 02:12:22 +01:00
private https : boolean ;
2026-01-30 17:50:02 +01:00
private testMode : boolean ;
2026-02-12 10:29:57 +01:00
private mux : TerminalMultiplexer ;
2026-01-18 21:36:03 +01:00
// Terminal batching for performance
2026-02-18 07:06:34 +01:00
private terminalBatches : Map < string , string [] > = new Map ();
2026-02-19 18:30:14 +01:00
private terminalBatchSizes : Map < string , number > = new Map (); // Running total avoids O(n) reduce per push
2026-01-18 21:36:03 +01:00
private terminalBatchTimer : NodeJS.Timeout | null = null ;
2026-02-04 07:24:32 +01:00
// 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
});
// Per-session adaptive batch intervals (sessions with rapid output get longer batches)
private adaptiveBatchIntervals : Map < string , number > = new Map ();
2026-02-12 20:14:01 +01:00
// Tracked minimum across adaptiveBatchIntervals (avoids spreading into Math.min on every batch)
private _minBatchInterval : number = TERMINAL_BATCH_INTERVAL ;
2026-01-19 06:51:25 +01:00
// Scheduled runs cleanup timer
private scheduledCleanupTimer : NodeJS.Timeout | null = null ;
// SSE event batching
2026-02-04 07:24:32 +01:00
private taskUpdateBatches : Map < string , { sessionId : string ; task : BackgroundTask }> = new Map ();
2026-01-19 06:51:25 +01:00
private taskUpdateBatchTimer : NodeJS.Timeout | null = null ;
2026-01-21 05:53:59 +01:00
// State update batching (reduce expensive toDetailedState() serialization)
private stateUpdatePending : Set < string > = new Set ();
private stateUpdateTimer : NodeJS.Timeout | null = null ;
2026-01-22 00:08:32 +01:00
// SSE client health check timer
private sseHealthCheckTimer : NodeJS.Timeout | null = null ;
// Flag to prevent new timers during shutdown
private _isStopping : boolean = false ;
2026-02-13 07:42:50 +01:00
// Cached light state for SSE init (avoids rebuilding on every reconnect)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private cachedLightState : { data : Record < string , unknown >; timestamp : number } | null = null ;
private static readonly LIGHT_STATE_CACHE_TTL_MS = 1000 ;
2026-02-15 05:33:34 +01:00
// Cached sessions list for getLightSessionsState() (avoids re-serializing all sessions on every call)
private cachedSessionsList : { data : unknown []; timestamp : number } | null = null ;
2026-01-25 20:36:04 +01:00
// 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 ;
2026-01-26 03:08:13 +01:00
// 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 ();
2026-01-28 01:25:25 +01:00
// Active plan orchestrators (for cancellation via API)
private activePlanOrchestrators : Map < string , PlanOrchestrator > = new Map ();
2026-02-15 05:33:34 +01:00
private persistDebounceTimers : Map < string , ReturnType < typeof setTimeout >> = new Map ();
2026-01-26 03:08:13 +01:00
// Grace period before starting restored respawn controllers (2 minutes)
private static readonly RESPAWN_RESTORE_GRACE_PERIOD_MS = 2 * 60 * 1000 ;
2026-01-30 13:25:18 +01:00
// 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 ;
2026-02-19 14:00:10 +01:00
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 ;
2026-01-30 17:50:02 +01:00
constructor ( port : number = 3000 , https : boolean = false , testMode : boolean = false ) {
2026-01-18 05:14:34 +01:00
super ();
2026-02-15 05:33:34 +01:00
this . setMaxListeners ( 0 );
2026-01-18 05:14:34 +01:00
this . port = port ;
2026-01-24 02:12:22 +01:00
this . https = https ;
2026-01-30 17:50:02 +01:00
this . testMode = testMode ;
2026-01-24 02:12:22 +01:00
if ( https ) {
const { key , cert } = getOrCreateSelfSignedCert ();
this . app = Fastify ({ logger : false , https : { key , cert } });
} else {
this . app = Fastify ({ logger : false });
}
2026-02-12 10:29:57 +01:00
this . mux = createMultiplexer ();
2026-01-19 02:18:51 +01:00
2026-02-18 12:51:35 +01:00
// Set up mux event listeners
2026-02-12 10:29:57 +01:00
this . mux . on ( 'sessionCreated' , ( session ) => {
2026-02-18 12:51:35 +01:00
this . broadcast ( 'mux:created' , session );
2026-02-12 10:29:57 +01:00
});
this . mux . on ( 'sessionKilled' , ( data ) => {
2026-02-18 12:51:35 +01:00
this . broadcast ( 'mux:killed' , data );
2026-02-12 10:29:57 +01:00
});
this . mux . on ( 'sessionDied' , ( data ) => {
2026-02-19 18:30:14 +01:00
getLifecycleLog (). log ({
event : 'mux_died' ,
sessionId : ( data as { sessionId? : string }). sessionId || 'unknown' ,
extra : data as Record < string , unknown >,
});
2026-02-18 12:51:35 +01:00
this . broadcast ( 'mux:died' , data );
2026-02-12 10:29:57 +01:00
});
2026-02-18 12:51:35 +01:00
this . mux . on ( 'statsUpdated' , ( sessions ) => {
this . broadcast ( 'mux:statsUpdated' , sessions );
2026-01-19 02:18:51 +01:00
});
2026-01-23 10:20:37 +01:00
2026-01-25 14:33:12 +01:00
// Set up subagent watcher listeners
this . setupSubagentWatcherListeners ();
2026-01-28 02:31:58 +01:00
// Set up image watcher listeners
this . setupImageWatcherListeners ();
2026-02-19 14:00:10 +01:00
// Set up team watcher listeners
this . setupTeamWatcherListeners ();
2026-01-25 14:33:12 +01:00
}
/**
* Set up event listeners for subagent watcher.
* Broadcasts real-time subagent activity to SSE clients.
2026-01-26 11:06:35 +01:00
*
* 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.
2026-01-25 14:33:12 +01:00
*/
private setupSubagentWatcherListeners () : void {
2026-01-30 13:25:18 +01:00
// 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 );
},
};
2026-01-25 14:33:12 +01:00
2026-01-30 13:25:18 +01:00
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 );
}
2026-01-25 19:49:58 +01:00
2026-01-30 13:25:18 +01:00
/**
* 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 ;
}
2026-01-18 05:14:34 +01:00
}
2026-01-28 02:31:58 +01:00
/**
* Set up event listeners for image watcher.
* Broadcasts image detection events to SSE clients for auto-popup.
*/
private setupImageWatcherListeners () : void {
2026-01-30 13:25:18 +01:00
// 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 );
},
};
2026-01-28 02:31:58 +01:00
2026-01-30 13:25:18 +01:00
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 ;
}
2026-01-28 02:31:58 +01:00
}
2026-02-19 14:00:10 +01:00
/**
* 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 ;
}
}
2026-01-18 05:14:34 +01:00
private async setupRoutes () : Promise < void > {
2026-02-16 12:10:44 +01:00
// 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 );
});
2026-02-13 21:21:33 +01:00
// 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 ,
});
2026-02-18 07:28:15 +01:00
// Optional HTTP Basic Auth (set CLAUDEMAN_PASSWORD env var to enable)
const authPassword = process . env . CLAUDEMAN_PASSWORD ;
if ( authPassword ) {
const authUsername = process . env . CLAUDEMAN_USERNAME || 'admin' ;
const expectedHeader = 'Basic ' + Buffer . from ( ` ${ authUsername } : ${ authPassword } ` ). toString ( 'base64' );
this . app . addHook ( 'onRequest' , ( req , reply , done ) => {
const auth = req . headers . authorization ;
if ( auth === expectedHeader ) {
done ();
return ;
}
reply . header ( 'WWW-Authenticate' , 'Basic realm="Claudeman"' );
reply . code ( 401 ). send ( 'Unauthorized' );
});
}
2026-02-18 10:23:10 +01:00
// Security headers + CORS on every response
this . app . addHook ( 'onRequest' , ( req , reply , done ) => {
2026-02-18 07:06:34 +01:00
reply . header ( 'X-Content-Type-Options' , 'nosniff' );
reply . header ( 'X-Frame-Options' , 'SAMEORIGIN' );
2026-02-18 10:23:10 +01:00
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'; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'" );
2026-02-18 07:06:34 +01:00
if ( this . https ) {
reply . header ( 'Strict-Transport-Security' , 'max-age=31536000; includeSubDomains' );
}
2026-02-18 10:23:10 +01:00
// 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 ;
}
2026-02-18 07:06:34 +01:00
done ();
});
2026-02-18 11:59:42 +01:00
// Serve static files — versioned assets (?v=X) are immutable, cache aggressively
2026-01-18 05:14:34 +01:00
await this . app . register ( fastifyStatic , {
root : join ( __dirname , 'public' ),
prefix : '/' ,
2026-02-18 11:59:42 +01:00
maxAge : '1y' ,
immutable : true ,
2026-01-18 05:14:34 +01:00
});
// SSE endpoint for real-time updates
this . app . get ( '/api/events' , ( req , reply ) => {
reply . raw . writeHead ( 200 , {
'Content-Type' : 'text/event-stream' ,
'Cache-Control' : 'no-cache' ,
'Connection' : 'keep-alive' ,
2026-01-24 04:35:58 +01:00
'X-Accel-Buffering' : 'no' , // Disable nginx buffering
2026-01-18 05:14:34 +01:00
});
this . sseClients . add ( reply );
// Send initial state
2026-01-26 21:27:08 +01:00
// 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 ());
2026-01-18 05:14:34 +01:00
req . raw . on ( 'close' , () => {
this . sseClients . delete ( reply );
2026-02-12 18:26:16 +01:00
this . backpressuredClients . delete ( reply );
2026-01-18 05:14:34 +01:00
});
});
// API Routes
2026-02-12 18:26:16 +01:00
this . app . get ( '/api/status' , async () => this . getLightState ());
2026-01-18 05:14:34 +01:00
2026-01-27 11:02:02 +01:00
// Cleanup stale sessions from state file
this . app . post ( '/api/cleanup-state' , async () => {
const cleaned = this . cleanupStaleSessions ();
return { success : true , cleanedSessions : cleaned };
});
2026-02-19 18:30:14 +01:00
// 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 };
});
2026-01-25 07:26:49 +01:00
// 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 (),
};
});
2026-01-25 20:36:04 +01:00
// 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 ),
};
});
2026-01-23 22:14:00 +01:00
this . app . get ( '/api/config' , async () => {
return { success : true , config : this.store.getConfig () };
});
this . app . put ( '/api/config' , async ( req ) => {
2026-02-04 07:24:32 +01:00
// 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 } ` );
2026-01-23 22:14:00 +01:00
}
2026-02-04 07:24:32 +01:00
this . store . setConfig ( parseResult . data as Partial < ReturnType < typeof this.store.getConfig >>);
2026-01-23 22:14:00 +01:00
return { success : true , config : this.store.getConfig () };
});
2026-01-27 00:29:38 +01:00
// Debug/monitoring endpoint - lightweight, only runs when called
2026-01-28 04:44:29 +01:00
// Returns comprehensive memory metrics for debugging memory leaks
2026-01-27 00:29:38 +01:00
this . app . get ( '/api/debug/memory' , async () => {
const mem = process . memoryUsage ();
2026-01-28 04:44:29 +01:00
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 );
2026-01-27 00:29:38 +01:00
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 ,
},
2026-01-28 04:44:29 +01:00
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 : {
2026-01-27 00:29:38 +01:00
respawnTimers : this.respawnTimers.size ,
2026-01-28 04:44:29 +01:00
pendingRespawnStarts : this.pendingRespawnStarts.size ,
subagentIdleTimers : subagentStats.idleTimerCount ,
total : this.respawnTimers.size + this . pendingRespawnStarts . size + subagentStats . idleTimerCount ,
2026-01-27 00:29:38 +01:00
},
uptime : {
seconds : Math.round ( process . uptime ()),
formatted : formatUptime ( process . uptime ()),
},
timestamp : Date.now (),
};
});
2026-01-18 05:14:34 +01:00
// Session management
2026-02-12 14:41:27 +01:00
this . app . get ( '/api/sessions' , async () => this . getLightSessionsState ());
2026-01-18 05:14:34 +01:00
2026-01-18 21:36:03 +01:00
this . app . post ( '/api/sessions' , async ( req ) : Promise < SessionResponse > => {
2026-01-19 11:19:13 +01:00
// Prevent unbounded session creation
if ( this . sessions . size >= MAX_CONCURRENT_SESSIONS ) {
2026-02-04 07:24:32 +01:00
return createErrorResponse ( ApiErrorCode . OPERATION_FAILED , `Maximum concurrent sessions ( ${ MAX_CONCURRENT_SESSIONS } ) reached. Delete some sessions first.` );
2026-01-19 11:19:13 +01:00
}
2026-02-04 07:24:32 +01:00
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 ;
2026-01-18 05:14:34 +01:00
const workingDir = body . workingDir || process . cwd ();
2026-02-12 08:34:51 +01:00
2026-02-18 10:23:10 +01:00
// 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' );
}
}
2026-02-12 08:34:51 +01:00
// Write env overrides to .claude/settings.local.json if provided
if ( body . envOverrides && Object . keys ( body . envOverrides ). length > 0 ) {
2026-02-18 07:06:34 +01:00
await updateCaseEnvVars ( workingDir , body . envOverrides );
2026-02-12 08:34:51 +01:00
}
2026-02-12 20:14:01 +01:00
const globalNice = await this . getGlobalNiceConfig ();
2026-02-18 23:14:39 +01:00
const modelConfig = await this . getModelConfig ();
const model = ( body . mode !== 'shell' ) ? modelConfig?.defaultModel : undefined ;
2026-01-19 01:26:38 +01:00
const session = new Session ({
workingDir ,
mode : body.mode || 'claude' ,
2026-01-19 03:40:57 +01:00
name : body.name || '' ,
2026-02-18 12:51:35 +01:00
mux : this.mux ,
useMux : true ,
2026-01-27 11:43:20 +01:00
niceConfig : globalNice ,
2026-02-18 23:14:39 +01:00
model ,
2026-01-19 01:26:38 +01:00
});
2026-01-18 05:14:34 +01:00
this . sessions . set ( session . id , session );
2026-01-25 07:26:49 +01:00
this . store . incrementSessionsCreated ();
2026-01-22 20:36:06 +01:00
this . persistSessionState ( session );
2026-02-12 20:14:01 +01:00
await this . setupSessionListeners ( session );
2026-02-19 18:30:14 +01:00
getLifecycleLog (). log ({ event : 'created' , sessionId : session.id , name : session.name });
2026-01-18 05:14:34 +01:00
2026-02-13 07:42:50 +01:00
const detailedState = session . toDetailedState ();
this . broadcast ( 'session:created' , detailedState );
return { success : true , session : detailedState };
2026-01-18 05:14:34 +01:00
});
2026-01-19 01:26:38 +01:00
// Rename a session
this . app . put ( '/api/sessions/:id/name' , async ( req ) => {
const { id } = req . params as { id : string };
2026-02-18 07:06:34 +01:00
const result = SessionNameSchema . safeParse ( req . body );
if ( ! result . success ) {
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , 'Invalid request body' );
}
const body = result . data ;
2026-01-19 01:26:38 +01:00
const session = this . sessions . get ( id );
if ( ! session ) {
2026-01-24 04:35:58 +01:00
return createErrorResponse ( ApiErrorCode . NOT_FOUND , 'Session not found' );
2026-01-19 01:26:38 +01:00
}
2026-01-24 04:35:58 +01:00
const name = String ( body . name || '' ). slice ( 0 , MAX_SESSION_NAME_LENGTH );
session . name = name ;
2026-02-18 12:51:35 +01:00
// Also update the mux session name if applicable
2026-02-12 10:29:57 +01:00
this . mux . updateSessionName ( id , session . name );
2026-01-22 20:36:06 +01:00
this . persistSessionState ( session );
2026-01-28 05:56:39 +01:00
this . broadcast ( 'session:updated' , this . getSessionStateWithRespawn ( session ));
2026-01-19 01:26:38 +01:00
return { success : true , name : session.name };
});
2026-01-30 13:25:18 +01:00
// Set session color
this . app . put ( '/api/sessions/:id/color' , async ( req ) => {
const { id } = req . params as { id : string };
2026-02-18 07:06:34 +01:00
const result = SessionColorSchema . safeParse ( req . body );
if ( ! result . success ) {
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , 'Invalid request body' );
}
const body = result . data ;
2026-01-30 13:25:18 +01:00
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 };
});
2026-01-18 21:36:03 +01:00
this . app . delete ( '/api/sessions/:id' , async ( req ) : Promise < ApiResponse > => {
2026-01-18 05:14:34 +01:00
const { id } = req . params as { id : string };
2026-02-18 12:51:35 +01:00
const query = req . query as { killMux? : string };
const killMux = query . killMux !== 'false' ; // Default to true
2026-01-18 05:14:34 +01:00
2026-01-19 11:19:13 +01:00
if ( ! this . sessions . has ( id )) {
2026-02-04 07:24:32 +01:00
return createErrorResponse ( ApiErrorCode . NOT_FOUND , 'Session not found' );
2026-01-18 05:14:34 +01:00
}
2026-02-19 18:30:14 +01:00
await this . cleanupSession ( id , killMux , 'user_delete' );
2026-01-18 05:14:34 +01:00
return { success : true };
});
2026-01-18 21:36:03 +01:00
// 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 ) {
2026-01-19 11:19:13 +01:00
if ( this . sessions . has ( id )) {
2026-02-19 18:30:14 +01:00
await this . cleanupSession ( id , true , 'user_bulk_delete' );
2026-01-18 21:36:03 +01:00
killed ++ ;
}
}
return { success : true , data : { killed } };
});
2026-01-18 05:14:34 +01:00
this . app . get ( '/api/sessions/:id' , async ( req ) => {
const { id } = req . params as { id : string };
const session = this . sessions . get ( id );
if ( ! session ) {
2026-01-21 22:36:45 +01:00
return createErrorResponse ( ApiErrorCode . NOT_FOUND , 'Session not found' );
2026-01-18 05:14:34 +01:00
}
2026-01-26 00:55:29 +01:00
// Include respawn controller state if active
const controller = this . respawnControllers . get ( id );
return {
... session . toDetailedState (),
respawnEnabled : controller?.getConfig () ? . enabled ?? false ,
respawnConfig : controller?.getConfig () ?? null ,
respawn : controller?.getStatus () ?? null ,
};
2026-01-18 05:14:34 +01:00
});
this . app . get ( '/api/sessions/:id/output' , async ( req ) => {
const { id } = req . params as { id : string };
const session = this . sessions . get ( id );
if ( ! session ) {
2026-01-21 22:36:45 +01:00
return createErrorResponse ( ApiErrorCode . NOT_FOUND , 'Session not found' );
2026-01-18 05:14:34 +01:00
}
return {
2026-01-21 22:36:45 +01:00
success : true ,
data : {
textOutput : session.textOutput ,
messages : session.messages ,
errorBuffer : session.errorBuffer ,
}
2026-01-18 05:14:34 +01:00
};
});
2026-01-21 22:36:45 +01:00
// Get Ralph state (Ralph loop + todos) for a session
this . app . get ( '/api/sessions/:id/ralph-state' , async ( req ) => {
2026-01-19 13:21:01 +01:00
const { id } = req . params as { id : string };
const session = this . sessions . get ( id );
if ( ! session ) {
2026-02-04 07:24:32 +01:00
return createErrorResponse ( ApiErrorCode . NOT_FOUND , 'Session not found' );
2026-01-19 13:21:01 +01:00
}
return {
success : true ,
data : {
2026-01-21 22:36:45 +01:00
loop : session.ralphLoopState ,
todos : session.ralphTodos ,
todoStats : session.ralphTodoStats ,
2026-01-19 13:21:01 +01:00
}
};
});
2026-01-25 22:05:28 +01:00
// 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 () };
});
2026-01-26 00:55:29 +01:00
// 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 ,
}
};
});
2026-01-27 01:02:38 +01:00
// 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' );
}
2026-02-04 07:24:32 +01:00
// Validate path is within working directory (security: proper path traversal check)
2026-01-27 01:02:38 +01:00
const fullPath = resolve ( session . workingDir , filePath );
2026-02-04 07:24:32 +01:00
const relativePath = relative ( session . workingDir , fullPath );
if ( relativePath . startsWith ( '..' ) || isAbsolute ( relativePath )) {
2026-01-27 01:02:38 +01:00
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 ) } ` ,
}
};
}
2026-02-04 07:24:32 +01:00
// 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 );
2026-01-27 01:02:38 +01:00
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 ) {
2026-02-04 07:24:32 +01:00
reply . code ( 404 ). send ( createErrorResponse ( ApiErrorCode . NOT_FOUND , 'Session not found' ));
2026-01-27 01:02:38 +01:00
return ;
}
if ( ! filePath ) {
2026-02-04 07:24:32 +01:00
reply . code ( 400 ). send ( createErrorResponse ( ApiErrorCode . INVALID_INPUT , 'Missing path parameter' ));
2026-01-27 01:02:38 +01:00
return ;
}
2026-02-04 07:24:32 +01:00
// Validate path is within working directory (security: proper path traversal check)
2026-01-27 01:02:38 +01:00
const fullPath = resolve ( session . workingDir , filePath );
2026-02-04 07:24:32 +01:00
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' ));
2026-01-27 01:02:38 +01:00
return ;
}
try {
2026-02-04 07:24:32 +01:00
// 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 ;
}
2026-01-27 01:02:38 +01:00
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 ) {
2026-02-04 07:24:32 +01:00
reply . code ( 500 ). send ( createErrorResponse ( ApiErrorCode . OPERATION_FAILED , `Failed to read file: ${ getErrorMessage ( err ) } ` ));
2026-01-27 01:02:38 +01:00
}
});
2026-01-26 00:55:29 +01:00
// 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 ) {
2026-02-04 07:24:32 +01:00
reply . code ( 404 ). send ( createErrorResponse ( ApiErrorCode . NOT_FOUND , 'Session not found' ));
2026-01-26 00:55:29 +01:00
return ;
}
if ( ! filePath ) {
2026-02-04 07:24:32 +01:00
reply . code ( 400 ). send ( createErrorResponse ( ApiErrorCode . INVALID_INPUT , 'Missing path parameter' ));
2026-01-26 00:55:29 +01:00
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 };
});
2026-01-21 22:36:45 +01:00
// Configure Ralph (Ralph Wiggum) settings
this . app . post ( '/api/sessions/:id/ralph-config' , async ( req ) => {
2026-01-19 18:25:31 +01:00
const { id } = req . params as { id : string };
2026-02-18 07:06:34 +01:00
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 {
2026-01-19 18:56:34 +01:00
enabled? : boolean ;
2026-01-19 18:25:31 +01:00
completionPhrase? : string ;
maxIterations? : number ;
2026-02-18 07:06:34 +01:00
reset? : boolean | 'full' ;
disableAutoEnable? : boolean ;
2026-01-19 18:25:31 +01:00
};
const session = this . sessions . get ( id );
if ( ! session ) {
2026-01-21 22:36:45 +01:00
return createErrorResponse ( ApiErrorCode . NOT_FOUND , 'Session not found' );
2026-01-19 18:25:31 +01:00
}
2026-01-21 04:34:45 +01:00
// Handle reset first (before other config)
if ( reset ) {
if ( reset === 'full' ) {
2026-01-21 22:36:45 +01:00
session . ralphTracker . fullReset ();
2026-01-21 04:34:45 +01:00
} else {
2026-01-21 22:36:45 +01:00
session . ralphTracker . reset ();
2026-01-21 04:34:45 +01:00
}
}
2026-01-21 13:38:12 +01:00
// Configure auto-enable behavior
if ( disableAutoEnable !== undefined ) {
if ( disableAutoEnable ) {
2026-01-21 22:36:45 +01:00
session . ralphTracker . disableAutoEnable ();
2026-01-21 13:38:12 +01:00
} else {
2026-01-21 22:36:45 +01:00
session . ralphTracker . enableAutoEnable ();
2026-01-21 13:38:12 +01:00
}
}
2026-01-19 18:56:34 +01:00
// Enable/disable the tracker
if ( enabled !== undefined ) {
if ( enabled ) {
2026-01-21 22:36:45 +01:00
session . ralphTracker . enable ();
2026-01-30 17:20:53 +01:00
// Allow re-enabling on restart if user explicitly enabled
session . ralphTracker . enableAutoEnable ();
2026-01-19 18:56:34 +01:00
} else {
2026-01-21 22:36:45 +01:00
session . ralphTracker . disable ();
2026-01-30 17:20:53 +01:00
// Prevent re-enabling on restart when user explicitly disabled
session . ralphTracker . disableAutoEnable ();
2026-01-19 18:56:34 +01:00
}
2026-01-21 22:36:45 +01:00
// Persist Ralph enabled state
2026-02-12 10:29:57 +01:00
this . mux . updateRalphEnabled ( id , enabled );
2026-01-19 18:56:34 +01:00
}
2026-01-21 22:36:45 +01:00
// Configure the Ralph tracker
2026-01-19 18:25:31 +01:00
if ( completionPhrase !== undefined ) {
// Start loop with completion phrase to set it up for watching
if ( completionPhrase ) {
2026-01-21 22:36:45 +01:00
session . ralphTracker . startLoop ( completionPhrase , maxIterations || undefined );
2026-01-19 18:25:31 +01:00
}
}
if ( maxIterations !== undefined ) {
2026-01-21 22:36:45 +01:00
session . ralphTracker . setMaxIterations ( maxIterations || null );
2026-01-19 18:25:31 +01:00
}
2026-01-22 20:36:06 +01:00
// Persist and broadcast the update
this . persistSessionState ( session );
2026-01-21 22:36:45 +01:00
this . broadcast ( 'session:ralphLoopUpdate' , {
2026-01-19 18:25:31 +01:00
sessionId : id ,
2026-01-21 22:36:45 +01:00
state : session.ralphLoopState
2026-01-19 18:25:31 +01:00
});
return { success : true };
});
2026-01-27 00:29:38 +01:00
// 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 ,
}
};
});
2026-01-27 01:02:38 +01:00
// 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 };
2026-02-18 07:06:34 +01:00
const importResult = FixPlanImportSchema . safeParse ( req . body );
if ( ! importResult . success ) {
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , 'Invalid request body' );
}
const { content } = importResult . data ;
2026-01-27 01:02:38 +01:00
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 ();
2026-02-19 08:49:36 +01:00
const filePath = join ( workingDir , '@fix_plan.md' );
2026-01-27 01:02:38 +01:00
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' );
}
2026-02-19 08:49:36 +01:00
const filePath = join ( workingDir , '@fix_plan.md' );
2026-01-27 01:02:38 +01:00
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 } ` );
}
});
2026-01-27 17:46:43 +01:00
// Write Ralph prompt to file in session's working directory
2026-02-18 12:51:35 +01:00
// This avoids mux input escaping issues with long multi-line prompts
2026-01-27 17:46:43 +01:00
this . app . post ( '/api/sessions/:id/ralph-prompt/write' , async ( req ) => {
const { id } = req . params as { id : string };
2026-02-18 07:06:34 +01:00
const promptResult = RalphPromptWriteSchema . safeParse ( req . body );
if ( ! promptResult . success ) {
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , 'Invalid request body' );
}
const { content } = promptResult . data ;
2026-01-27 17:46:43 +01:00
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' );
}
2026-02-19 08:49:36 +01:00
const filePath = join ( workingDir , '@ralph_prompt.md' );
2026-01-27 17:46:43 +01:00
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 } ` );
}
});
2026-01-18 05:14:34 +01:00
// Run prompt in session
2026-01-24 04:35:58 +01:00
this . app . post ( '/api/sessions/:id/run' , async ( req ) : Promise < ApiResponse > => {
2026-01-18 05:14:34 +01:00
const { id } = req . params as { id : string };
2026-02-04 07:24:32 +01:00
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 ;
2026-01-18 05:14:34 +01:00
const session = this . sessions . get ( id );
if ( ! session ) {
2026-01-24 04:35:58 +01:00
return createErrorResponse ( ApiErrorCode . NOT_FOUND , 'Session not found' );
2026-01-18 05:14:34 +01:00
}
if ( session . isBusy ()) {
2026-01-24 04:35:58 +01:00
return createErrorResponse ( ApiErrorCode . SESSION_BUSY , 'Session is busy' );
2026-01-18 05:14:34 +01:00
}
// Run async, don't wait
session . runPrompt ( prompt ). catch ( err => {
this . broadcast ( 'session:error' , { id , error : err.message });
});
this . broadcast ( 'session:running' , { id , prompt });
2026-01-24 04:35:58 +01:00
return { success : true };
2026-01-18 05:14:34 +01:00
});
2026-01-18 07:31:19 +01:00
// Start interactive Claude session (persists even if browser disconnects)
2026-01-24 04:35:58 +01:00
this . app . post ( '/api/sessions/:id/interactive' , async ( req ) : Promise < ApiResponse > => {
2026-01-18 07:31:19 +01:00
const { id } = req . params as { id : string };
const session = this . sessions . get ( id );
if ( ! session ) {
2026-01-24 04:35:58 +01:00
return createErrorResponse ( ApiErrorCode . NOT_FOUND , 'Session not found' );
2026-01-18 07:31:19 +01:00
}
if ( session . isBusy ()) {
2026-01-24 04:35:58 +01:00
return createErrorResponse ( ApiErrorCode . SESSION_BUSY , 'Session is busy' );
2026-01-18 07:31:19 +01:00
}
try {
2026-01-30 17:20:53 +01:00
// 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 ) {
2026-01-23 22:14:00 +01:00
autoConfigureRalph ( session , session . workingDir , () => {});
if ( ! session . ralphTracker . enabled ) {
session . ralphTracker . enable ();
}
}
2026-01-21 19:49:44 +01:00
2026-01-18 07:31:19 +01:00
await session . startInteractive ();
2026-02-19 18:30:14 +01:00
getLifecycleLog (). log ({ event : 'started' , sessionId : id , name : session.name , mode : 'claude' });
2026-01-18 07:31:19 +01:00
this . broadcast ( 'session:interactive' , { id });
2026-01-28 05:56:39 +01:00
this . broadcast ( 'session:updated' , { session : this.getSessionStateWithRespawn ( session ) });
2026-01-21 19:49:44 +01:00
2026-01-24 04:35:58 +01:00
return { success : true };
2026-01-18 07:31:19 +01:00
} catch ( err ) {
2026-01-24 04:35:58 +01:00
return createErrorResponse ( ApiErrorCode . OPERATION_FAILED , getErrorMessage ( err ));
2026-01-18 07:31:19 +01:00
}
});
2026-01-19 01:26:38 +01:00
// Start a plain shell session (no Claude)
2026-01-24 04:35:58 +01:00
this . app . post ( '/api/sessions/:id/shell' , async ( req ) : Promise < ApiResponse > => {
2026-01-19 01:26:38 +01:00
const { id } = req . params as { id : string };
const session = this . sessions . get ( id );
if ( ! session ) {
2026-01-24 04:35:58 +01:00
return createErrorResponse ( ApiErrorCode . NOT_FOUND , 'Session not found' );
2026-01-19 01:26:38 +01:00
}
if ( session . isBusy ()) {
2026-01-24 04:35:58 +01:00
return createErrorResponse ( ApiErrorCode . SESSION_BUSY , 'Session is busy' );
2026-01-19 01:26:38 +01:00
}
try {
await session . startShell ();
2026-02-19 18:30:14 +01:00
getLifecycleLog (). log ({ event : 'started' , sessionId : id , name : session.name , mode : 'shell' });
2026-01-19 01:26:38 +01:00
this . broadcast ( 'session:interactive' , { id , mode : 'shell' });
2026-01-28 05:56:39 +01:00
this . broadcast ( 'session:updated' , { session : this.getSessionStateWithRespawn ( session ) });
2026-01-24 04:35:58 +01:00
return { success : true };
2026-01-19 01:26:38 +01:00
} catch ( err ) {
2026-01-24 04:35:58 +01:00
return createErrorResponse ( ApiErrorCode . OPERATION_FAILED , getErrorMessage ( err ));
2026-01-19 01:26:38 +01:00
}
});
2026-01-18 07:31:19 +01:00
// Send input to interactive session
2026-02-18 12:51:35 +01:00
// useMux: true uses writeViaMux which is more reliable for programmatic input
2026-01-18 21:36:03 +01:00
this . app . post ( '/api/sessions/:id/input' , async ( req ) : Promise < ApiResponse > => {
2026-01-18 07:31:19 +01:00
const { id } = req . params as { id : string };
2026-02-18 07:06:34 +01:00
const result = SessionInputWithLimitSchema . safeParse ( req . body );
2026-02-04 07:24:32 +01:00
if ( ! result . success ) {
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , result . error . issues [ 0 ] ? . message ?? 'Validation failed' );
}
2026-02-18 12:51:35 +01:00
const { input , useMux } = result . data ;
2026-01-18 07:31:19 +01:00
const session = this . sessions . get ( id );
if ( ! session ) {
2026-01-21 16:20:56 +01:00
return createErrorResponse ( ApiErrorCode . NOT_FOUND , 'Session not found' );
2026-01-18 07:31:19 +01:00
}
2026-01-24 04:35:58 +01:00
const inputStr = String ( input );
if ( inputStr . length > MAX_INPUT_LENGTH ) {
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , `Input exceeds maximum length ( ${ MAX_INPUT_LENGTH } bytes)` );
}
2026-02-18 12:51:35 +01:00
// Write input to PTY. Direct write is synchronous; writeViaMux
2026-02-13 20:25:56 +01:00
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
2026-02-18 12:51:35 +01:00
if ( useMux ) {
2026-02-13 20:25:56 +01:00
// Fire-and-forget: don't block HTTP response on tmux child process.
// Fallback to direct write on failure.
2026-02-18 12:51:35 +01:00
session . writeViaMux ( inputStr ). then ( ok => {
2026-02-13 20:25:56 +01:00
if ( ! ok ) {
2026-02-18 12:51:35 +01:00
console . warn ( `[Server] writeViaMux failed for session ${ id } , falling back to direct write` );
2026-02-13 20:25:56 +01:00
session . write ( inputStr );
}
}). catch (() => {
2026-01-27 11:02:02 +01:00
session . write ( inputStr );
2026-02-13 20:25:56 +01:00
});
2026-01-27 11:02:02 +01:00
} else {
session . write ( inputStr );
}
2026-02-13 20:25:56 +01:00
return { success : true };
2026-01-18 07:31:19 +01:00
});
// Resize session terminal
2026-01-18 21:36:03 +01:00
this . app . post ( '/api/sessions/:id/resize' , async ( req ) : Promise < ApiResponse > => {
2026-01-18 07:31:19 +01:00
const { id } = req . params as { id : string };
2026-02-04 07:24:32 +01:00
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 ;
2026-01-18 07:31:19 +01:00
const session = this . sessions . get ( id );
if ( ! session ) {
2026-01-21 16:20:56 +01:00
return createErrorResponse ( ApiErrorCode . NOT_FOUND , 'Session not found' );
}
2026-02-04 07:24:32 +01:00
// Note: Zod already validates that cols and rows are positive integers within bounds
2026-01-24 04:35:58 +01:00
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 } )` );
}
2026-01-18 07:31:19 +01:00
session . resize ( cols , rows );
return { success : true };
});
// Get session terminal buffer (for reconnecting)
2026-01-21 23:55:16 +01:00
// Query params:
// tail=<bytes> - Only return last N bytes (faster initial load)
2026-01-18 07:31:19 +01:00
this . app . get ( '/api/sessions/:id/terminal' , async ( req ) => {
const { id } = req . params as { id : string };
2026-01-21 23:55:16 +01:00
const query = req . query as { tail? : string };
2026-01-18 07:31:19 +01:00
const session = this . sessions . get ( id );
if ( ! session ) {
2026-01-21 22:36:45 +01:00
return createErrorResponse ( ApiErrorCode . NOT_FOUND , 'Session not found' );
2026-01-18 07:31:19 +01:00
}
2026-01-21 04:34:45 +01:00
// Clean the buffer: remove junk before actual Claude content
let cleanBuffer = session . terminalBuffer ;
// Find where Claude banner starts (has color codes before "Claude")
2026-01-24 04:35:58 +01:00
const claudeMatch = cleanBuffer . match ( CLAUDE_BANNER_PATTERN );
2026-01-21 04:34:45 +01:00
if ( claudeMatch && claudeMatch . index !== undefined && claudeMatch . index > 0 ) {
2026-01-24 04:35:58 +01:00
// Find the start of that line
2026-01-21 04:34:45 +01:00
let lineStart = claudeMatch . index ;
while ( lineStart > 0 && cleanBuffer [ lineStart - 1 ] !== '\n' ) {
lineStart -- ;
}
cleanBuffer = cleanBuffer . slice ( lineStart );
}
2026-01-24 04:35:58 +01:00
// Remove Ctrl+L and leading whitespace
2026-01-21 04:34:45 +01:00
cleanBuffer = cleanBuffer
2026-01-24 04:35:58 +01:00
. replace ( CTRL_L_PATTERN , '' )
. replace ( LEADING_WHITESPACE_PATTERN , '' );
2026-01-21 04:34:45 +01:00
2026-01-21 23:55:16 +01:00
// Optionally truncate to last N bytes for faster initial load
const tailBytes = query . tail ? parseInt ( query . tail , 10 ) : 0 ;
const fullSize = cleanBuffer . length ;
let truncated = false ;
if ( tailBytes > 0 && cleanBuffer . length > tailBytes ) {
cleanBuffer = cleanBuffer . slice ( - tailBytes );
truncated = true ;
2026-02-18 21:53:34 +01:00
// 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 );
}
2026-01-21 23:55:16 +01:00
}
2026-01-18 07:31:19 +01:00
return {
2026-01-21 04:34:45 +01:00
terminalBuffer : cleanBuffer ,
2026-01-18 07:31:19 +01:00
status : session.status ,
2026-01-21 23:55:16 +01:00
fullSize ,
truncated ,
2026-01-18 07:31:19 +01:00
};
});
// ============ 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 (),
};
});
2026-01-22 20:49:09 +01:00
// 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 };
}
2026-02-18 12:51:35 +01:00
// Return pre-saved config from mux-sessions.json
2026-02-12 10:29:57 +01:00
const preConfig = this . mux . getSession ( id ) ? . respawnConfig ;
2026-01-22 20:49:09 +01:00
if ( preConfig ) {
return { success : true , config : preConfig , active : false };
}
return { success : true , config : null , active : false };
});
2026-01-18 07:31:19 +01:00
// Start respawn controller for a session
this . app . post ( '/api/sessions/:id/respawn/start' , async ( req ) => {
const { id } = req . params as { id : string };
2026-02-18 07:06:34 +01:00
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 >;
}
2026-01-18 07:31:19 +01:00
const session = this . sessions . get ( id );
if ( ! session ) {
2026-01-21 22:36:45 +01:00
return createErrorResponse ( ApiErrorCode . NOT_FOUND , 'Session not found' );
2026-01-18 07:31:19 +01:00
}
// Create or get existing controller
let controller = this . respawnControllers . get ( id );
if ( ! controller ) {
2026-02-18 12:51:35 +01:00
// Merge request body with pre-saved config from mux-sessions.json
2026-02-12 10:29:57 +01:00
const preConfig = this . mux . getSession ( id ) ? . respawnConfig ;
2026-01-22 20:49:09 +01:00
const config = body || preConfig ? { ... preConfig , ... body } : undefined ;
controller = new RespawnController ( session , config );
2026-01-18 07:31:19 +01:00
this . respawnControllers . set ( id , controller );
this . setupRespawnListeners ( id , controller );
} else if ( body ) {
controller . updateConfig ( body );
}
controller . start ();
2026-01-21 18:30:06 +01:00
2026-02-12 13:44:07 +01:00
// Persist respawn config to mux session and state.json
2026-01-21 18:30:06 +01:00
this . saveRespawnConfig ( id , controller . getConfig ());
2026-01-22 20:36:06 +01:00
this . persistSessionState ( session );
2026-01-21 18:30:06 +01:00
2026-01-18 07:31:19 +01:00
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 ) {
2026-01-21 22:36:45 +01:00
return createErrorResponse ( ApiErrorCode . NOT_FOUND , 'Respawn controller not found' );
2026-01-18 07:31:19 +01:00
}
controller . stop ();
2026-01-21 18:30:06 +01:00
2026-01-25 08:01:57 +01:00
// 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 );
}
2026-01-21 18:30:06 +01:00
// Clear persisted respawn config
2026-02-12 10:29:57 +01:00
this . mux . clearRespawnConfig ( id );
2026-01-21 18:30:06 +01:00
2026-01-22 20:36:06 +01:00
// Update state.json (respawnConfig removed)
const session = this . sessions . get ( id );
if ( session ) {
this . persistSessionState ( session );
}
2026-01-18 07:31:19 +01:00
this . broadcast ( 'respawn:stopped' , { sessionId : id });
return { success : true };
});
2026-01-22 20:49:09 +01:00
// Update respawn configuration (works with or without running controller)
2026-01-18 07:31:19 +01:00
this . app . put ( '/api/sessions/:id/respawn/config' , async ( req ) => {
const { id } = req . params as { id : string };
2026-02-04 07:24:32 +01:00
// 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 >;
2026-01-22 20:49:09 +01:00
const session = this . sessions . get ( id );
if ( ! session ) {
return createErrorResponse ( ApiErrorCode . NOT_FOUND , 'Session not found' );
}
2026-01-18 07:31:19 +01:00
const controller = this . respawnControllers . get ( id );
2026-01-22 20:49:09 +01:00
if ( controller ) {
// Update running controller
controller . updateConfig ( config );
this . saveRespawnConfig ( id , controller . getConfig ());
2026-01-22 20:36:06 +01:00
this . persistSessionState ( session );
2026-01-22 20:49:09 +01:00
this . broadcast ( 'respawn:configUpdated' , { sessionId : id , config : controller.getConfig () });
return { success : true , config : controller.getConfig () };
2026-01-22 20:36:06 +01:00
}
2026-01-21 18:30:06 +01:00
2026-01-22 20:49:09 +01:00
// No controller running - save as pre-config for when respawn starts
2026-02-12 10:29:57 +01:00
const existing = this . mux . getSession ( id );
2026-01-22 20:49:09 +01:00
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 ,
2026-01-23 01:25:09 +01:00
autoAcceptPrompts : config.autoAcceptPrompts ?? currentConfig ? . autoAcceptPrompts ?? true ,
autoAcceptDelayMs : config.autoAcceptDelayMs ?? currentConfig ? . autoAcceptDelayMs ?? 8000 ,
2026-01-24 18:48:37 +01:00
aiIdleCheckEnabled : config.aiIdleCheckEnabled ?? currentConfig ? . aiIdleCheckEnabled ?? true ,
2026-01-25 07:26:49 +01:00
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 ,
2026-01-25 02:55:43 +01:00
aiPlanCheckEnabled : config.aiPlanCheckEnabled ?? currentConfig ? . aiPlanCheckEnabled ?? true ,
2026-01-25 07:26:49 +01:00
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 ,
2026-01-22 20:49:09 +01:00
durationMinutes : currentConfig?.durationMinutes ,
};
2026-02-12 10:29:57 +01:00
this . mux . updateRespawnConfig ( id , merged );
2026-01-22 20:49:09 +01:00
this . persistSessionState ( session );
this . broadcast ( 'respawn:configUpdated' , { sessionId : id , config : merged });
return { success : true , config : merged };
2026-01-18 07:31:19 +01:00
});
// Start interactive session WITH respawn enabled
this . app . post ( '/api/sessions/:id/interactive-respawn' , async ( req ) => {
const { id } = req . params as { id : string };
2026-02-18 07:06:34 +01:00
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 };
2026-01-18 07:31:19 +01:00
const session = this . sessions . get ( id );
if ( ! session ) {
2026-01-21 22:36:45 +01:00
return createErrorResponse ( ApiErrorCode . NOT_FOUND , 'Session not found' );
2026-01-18 07:31:19 +01:00
}
if ( session . isBusy ()) {
2026-01-21 22:36:45 +01:00
return createErrorResponse ( ApiErrorCode . SESSION_BUSY , 'Session is busy' );
2026-01-18 07:31:19 +01:00
}
try {
2026-01-30 17:20:53 +01:00
// 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 ) {
2026-01-23 22:14:00 +01:00
autoConfigureRalph ( session , session . workingDir , () => {});
if ( ! session . ralphTracker . enabled ) {
session . ralphTracker . enable ();
}
}
2026-01-21 19:49:44 +01:00
2026-01-18 07:31:19 +01:00
// Start interactive session
await session . startInteractive ();
2026-02-19 18:30:14 +01:00
getLifecycleLog (). log ({ event : 'started' , sessionId : id , name : session.name , mode : 'claude' , reason : 'interactive_respawn' });
2026-01-18 07:31:19 +01:00
this . broadcast ( 'session:interactive' , { id });
2026-01-28 05:56:39 +01:00
this . broadcast ( 'session:updated' , { session : this.getSessionStateWithRespawn ( session ) });
2026-01-18 07:31:19 +01:00
// Create and start respawn controller
const controller = new RespawnController ( session , body ? . respawnConfig );
this . respawnControllers . set ( id , controller );
this . setupRespawnListeners ( id , controller );
controller . start ();
2026-01-19 00:38:11 +01:00
// Set up timed stop if duration specified
if ( body ? . durationMinutes && body . durationMinutes > 0 ) {
this . setupTimedRespawn ( id , body . durationMinutes );
}
2026-01-22 20:36:06 +01:00
// Persist full session state with respawn config
this . persistSessionState ( session );
2026-01-18 07:31:19 +01:00
this . broadcast ( 'respawn:started' , { sessionId : id , status : controller.getStatus () });
return {
success : true ,
2026-01-21 22:36:45 +01:00
data : {
message : 'Interactive session with respawn started' ,
respawnStatus : controller.getStatus (),
},
2026-01-18 07:31:19 +01:00
};
} catch ( err ) {
2026-01-21 22:36:45 +01:00
return createErrorResponse ( ApiErrorCode . OPERATION_FAILED , getErrorMessage ( err ));
2026-01-18 07:31:19 +01:00
}
});
2026-01-19 00:38:11 +01:00
// Enable respawn on an EXISTING interactive session
this . app . post ( '/api/sessions/:id/respawn/enable' , async ( req ) => {
const { id } = req . params as { id : string };
2026-02-18 07:06:34 +01:00
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 };
2026-01-19 00:38:11 +01:00
const session = this . sessions . get ( id );
if ( ! session ) {
2026-01-21 22:36:45 +01:00
return createErrorResponse ( ApiErrorCode . NOT_FOUND , 'Session not found' );
2026-01-19 00:38:11 +01:00
}
// Check if session is running (has a PID)
if ( ! session . pid ) {
2026-01-21 22:36:45 +01:00
return createErrorResponse ( ApiErrorCode . OPERATION_FAILED , 'Session is not running. Start it first.' );
2026-01-19 00:38:11 +01:00
}
// Stop existing controller if any
const existingController = this . respawnControllers . get ( id );
if ( existingController ) {
existingController . stop ();
}
2026-01-22 20:49:09 +01:00
// Create and start new respawn controller (merge with pre-saved config)
2026-02-12 10:29:57 +01:00
const preConfig = this . mux . getSession ( id ) ? . respawnConfig ;
2026-01-22 20:49:09 +01:00
const config = body ? . config || preConfig ? { ... preConfig , ... body ? . config } : undefined ;
const controller = new RespawnController ( session , config );
2026-01-19 00:38:11 +01:00
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 );
}
2026-02-12 13:44:07 +01:00
// Persist respawn config to mux session and state.json
2026-01-21 18:30:06 +01:00
this . saveRespawnConfig ( id , controller . getConfig (), body ? . durationMinutes );
2026-01-22 20:36:06 +01:00
this . persistSessionState ( session );
2026-01-21 18:30:06 +01:00
2026-01-19 00:38:11 +01:00
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 };
2026-02-18 07:06:34 +01:00
const acResult = AutoClearSchema . safeParse ( req . body );
if ( ! acResult . success ) {
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , 'Invalid request body' );
}
const body = acResult . data ;
2026-01-19 00:38:11 +01:00
const session = this . sessions . get ( id );
if ( ! session ) {
2026-01-21 22:36:45 +01:00
return createErrorResponse ( ApiErrorCode . NOT_FOUND , 'Session not found' );
}
2026-01-19 00:38:11 +01:00
session . setAutoClear ( body . enabled , body . threshold );
2026-01-22 20:36:06 +01:00
this . persistSessionState ( session );
2026-01-28 05:56:39 +01:00
this . broadcast ( 'session:updated' , this . getSessionStateWithRespawn ( session ));
2026-01-19 00:38:11 +01:00
return {
success : true ,
2026-01-21 22:36:45 +01:00
data : {
autoClear : {
enabled : session.autoClearEnabled ,
threshold : session.autoClearThreshold ,
},
2026-01-19 00:38:11 +01:00
},
};
});
2026-01-19 13:08:53 +01:00
// Set auto-compact on a session
this . app . post ( '/api/sessions/:id/auto-compact' , async ( req ) => {
const { id } = req . params as { id : string };
2026-02-18 07:06:34 +01:00
const compactResult = AutoCompactSchema . safeParse ( req . body );
if ( ! compactResult . success ) {
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , 'Invalid request body' );
}
const body = compactResult . data ;
2026-01-19 13:08:53 +01:00
const session = this . sessions . get ( id );
if ( ! session ) {
2026-01-21 22:36:45 +01:00
return createErrorResponse ( ApiErrorCode . NOT_FOUND , 'Session not found' );
}
2026-01-19 13:08:53 +01:00
session . setAutoCompact ( body . enabled , body . threshold , body . prompt );
2026-01-22 20:36:06 +01:00
this . persistSessionState ( session );
2026-01-28 05:56:39 +01:00
this . broadcast ( 'session:updated' , this . getSessionStateWithRespawn ( session ));
2026-01-19 13:08:53 +01:00
return {
success : true ,
2026-01-21 22:36:45 +01:00
data : {
autoCompact : {
enabled : session.autoCompactEnabled ,
threshold : session.autoCompactThreshold ,
prompt : session.autoCompactPrompt ,
},
2026-01-19 13:08:53 +01:00
},
};
});
2026-01-28 07:07:18 +01:00
// Toggle image watcher for a session
this . app . post ( '/api/sessions/:id/image-watcher' , async ( req ) => {
const { id } = req . params as { id : string };
2026-02-18 07:06:34 +01:00
const iwResult = ImageWatcherSchema . safeParse ( req . body );
if ( ! iwResult . success ) {
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , 'Invalid request body' );
}
const body = iwResult . data ;
2026-01-28 07:07:18 +01:00
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 ,
},
};
});
2026-01-30 17:50:02 +01:00
// Toggle flicker filter for a session
this . app . post ( '/api/sessions/:id/flicker-filter' , async ( req ) => {
const { id } = req . params as { id : string };
2026-02-18 07:06:34 +01:00
const ffResult = FlickerFilterSchema . safeParse ( req . body );
if ( ! ffResult . success ) {
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , 'Invalid request body' );
}
const body = ffResult . data ;
2026-01-30 17:50:02 +01:00
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 ,
},
};
});
2026-01-19 11:19:13 +01:00
// Quick run (create session, run prompt, return result, then cleanup)
2026-01-18 05:14:34 +01:00
this . app . post ( '/api/run' , async ( req ) => {
2026-01-19 11:19:13 +01:00
// Prevent unbounded session creation
if ( this . sessions . size >= MAX_CONCURRENT_SESSIONS ) {
2026-01-21 22:36:45 +01:00
return createErrorResponse ( ApiErrorCode . SESSION_BUSY , `Maximum concurrent sessions ( ${ MAX_CONCURRENT_SESSIONS } ) reached` );
2026-01-19 11:19:13 +01:00
}
2026-02-18 07:06:34 +01:00
const qrResult = QuickRunSchema . safeParse ( req . body );
if ( ! qrResult . success ) {
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , 'Invalid request body' );
}
const { prompt , workingDir } = qrResult . data ;
2026-01-21 22:36:45 +01:00
2026-02-15 05:26:29 +01:00
if ( ! prompt . trim ()) {
2026-01-21 22:36:45 +01:00
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , 'prompt is required' );
}
2026-01-18 05:14:34 +01:00
const dir = workingDir || process . cwd ();
2026-02-18 10:23:10 +01:00
// 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' );
}
}
2026-01-18 05:14:34 +01:00
const session = new Session ({ workingDir : dir });
this . sessions . set ( session . id , session );
2026-01-25 07:26:49 +01:00
this . store . incrementSessionsCreated ();
2026-01-22 20:36:06 +01:00
this . persistSessionState ( session );
2026-02-12 20:14:01 +01:00
await this . setupSessionListeners ( session );
2026-02-19 18:30:14 +01:00
getLifecycleLog (). log ({ event : 'created' , sessionId : session.id , name : session.name , reason : 'run_prompt' });
2026-01-18 05:14:34 +01:00
this . broadcast ( 'session:created' , session . toDetailedState ());
try {
const result = await session . runPrompt ( prompt );
2026-01-19 11:19:13 +01:00
// Clean up session after completion to prevent memory leak
2026-02-19 18:30:14 +01:00
await this . cleanupSession ( session . id , true , 'run_prompt_complete' );
2026-01-18 05:14:34 +01:00
return { success : true , sessionId : session.id , ... result };
} catch ( err ) {
2026-01-19 11:19:13 +01:00
// Clean up session on error too
2026-02-19 18:30:14 +01:00
await this . cleanupSession ( session . id , true , 'run_prompt_error' );
2026-01-21 14:07:04 +01:00
return { success : false , sessionId : session.id , error : getErrorMessage ( err ) };
2026-01-18 05:14:34 +01:00
}
});
// Scheduled runs
this . app . get ( '/api/scheduled' , async () => {
return Array . from ( this . scheduledRuns . values ());
});
2026-02-18 07:06:34 +01:00
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 ;
2026-01-18 05:14:34 +01:00
2026-02-18 10:23:10 +01:00
// 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' );
}
}
2026-02-15 05:26:29 +01:00
const run = await this . startScheduledRun ( prompt , workingDir || process . cwd (), durationMinutes ?? 60 );
2026-01-18 05:14:34 +01:00
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 ) {
2026-02-04 07:24:32 +01:00
return createErrorResponse ( ApiErrorCode . NOT_FOUND , 'Scheduled run not found' );
2026-01-18 05:14:34 +01:00
}
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 ) {
2026-01-24 04:35:58 +01:00
return createErrorResponse ( ApiErrorCode . NOT_FOUND , 'Scheduled run not found' );
2026-01-18 05:14:34 +01:00
}
return run ;
});
2026-01-18 07:31:19 +01:00
// Case management
const casesDir = join ( homedir (), 'claudeman-cases' );
2026-01-18 21:36:03 +01:00
this . app . get ( '/api/cases' , async () : Promise < CaseInfo [] > => {
2026-01-21 13:20:43 +01:00
const cases : CaseInfo [] = [];
// Get cases from casesDir
2026-02-18 07:06:34 +01:00
try {
const entries = await fs . readdir ( casesDir , { withFileTypes : true });
2026-01-21 13:20:43 +01:00
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' )),
});
}
}
2026-02-18 07:06:34 +01:00
} catch {
// casesDir may not exist yet
2026-01-18 07:31:19 +01:00
}
2026-01-21 13:20:43 +01:00
// Get linked cases
const linkedCasesFile = join ( homedir (), '.claudeman' , 'linked-cases.json' );
try {
2026-02-12 20:14:01 +01:00
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' )),
});
2026-01-21 13:20:43 +01:00
}
}
2026-02-04 07:24:32 +01:00
} catch ( err ) {
2026-02-12 20:14:01 +01:00
if (( err as NodeJS . ErrnoException ). code !== 'ENOENT' ) {
console . warn ( '[Server] Failed to read linked cases:' , err );
}
2026-01-21 13:20:43 +01:00
}
return cases ;
2026-01-18 07:31:19 +01:00
});
2026-02-04 07:24:32 +01:00
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' );
2026-01-18 07:31:19 +01:00
}
2026-02-04 07:24:32 +01:00
const { name , description } = result . data ;
2026-01-18 07:31:19 +01:00
const casePath = join ( casesDir , name );
2026-02-04 07:24:32 +01:00
// Security: Path traversal protection - use relative path check
2026-01-21 09:54:42 +01:00
const resolvedPath = resolve ( casePath );
const resolvedBase = resolve ( casesDir );
2026-02-04 07:24:32 +01:00
const relPath = relative ( resolvedBase , resolvedPath );
if ( relPath . startsWith ( '..' ) || isAbsolute ( relPath )) {
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , 'Invalid case path' );
2026-01-21 09:54:42 +01:00
}
2026-01-18 07:31:19 +01:00
if ( existsSync ( casePath )) {
2026-02-04 07:24:32 +01:00
return createErrorResponse ( ApiErrorCode . ALREADY_EXISTS , 'Case already exists' );
2026-01-18 07:31:19 +01:00
}
try {
mkdirSync ( casePath , { recursive : true });
mkdirSync ( join ( casePath , 'src' ), { recursive : true });
2026-01-19 02:12:32 +01:00
// Read settings to get custom template path
2026-02-12 20:14:01 +01:00
const templatePath = await this . getDefaultClaudeMdPath ();
2026-01-19 02:12:32 +01:00
const claudeMd = generateClaudeMd ( name , description || '' , templatePath );
2026-01-18 07:31:19 +01:00
writeFileSync ( join ( casePath , 'CLAUDE.md' ), claudeMd );
2026-01-24 02:46:24 +01:00
// Write .claude/settings.local.json with hooks for desktop notifications
2026-02-18 07:06:34 +01:00
await writeHooksConfig ( casePath );
2026-01-24 02:46:24 +01:00
2026-01-18 07:31:19 +01:00
this . broadcast ( 'case:created' , { name , path : casePath });
2026-02-04 07:24:32 +01:00
return { success : true , data : { case : { name , path : casePath } } };
2026-01-18 07:31:19 +01:00
} catch ( err ) {
2026-02-04 07:24:32 +01:00
return createErrorResponse ( ApiErrorCode . OPERATION_FAILED , getErrorMessage ( err ));
2026-01-18 07:31:19 +01:00
}
});
2026-01-21 13:20:43 +01:00
// Link an existing folder as a case
2026-02-04 07:24:32 +01:00
this . app . post ( '/api/cases/link' , async ( req ) : Promise < ApiResponse < { case : { name : string ; path : string } }> > => {
2026-02-18 07:06:34 +01:00
const lcResult = LinkCaseSchema . safeParse ( req . body );
if ( ! lcResult . success ) {
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , 'Invalid request body' );
2026-01-21 13:20:43 +01:00
}
2026-02-18 07:06:34 +01:00
const { name , path : folderPath } = lcResult . data ;
2026-01-21 13:20:43 +01:00
// Expand ~ to home directory
const expandedPath = folderPath . startsWith ( '~' )
? join ( homedir (), folderPath . slice ( 1 ))
: folderPath ;
// Validate the folder exists
if ( ! existsSync ( expandedPath )) {
2026-02-04 07:24:32 +01:00
return createErrorResponse ( ApiErrorCode . NOT_FOUND , `Folder not found: ${ expandedPath } ` );
2026-01-21 13:20:43 +01:00
}
// Check if case name already exists in casesDir
const casePath = join ( casesDir , name );
if ( existsSync ( casePath )) {
2026-02-04 07:24:32 +01:00
return createErrorResponse ( ApiErrorCode . ALREADY_EXISTS , 'A case with this name already exists in claudeman-cases.' );
2026-01-21 13:20:43 +01:00
}
// Load existing linked cases
const linkedCasesFile = join ( homedir (), '.claudeman' , 'linked-cases.json' );
let linkedCases : Record < string , string > = {};
try {
2026-02-12 20:14:01 +01:00
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 );
2026-01-21 13:20:43 +01:00
}
}
// Check if name is already linked
if ( linkedCases [ name ]) {
2026-02-04 07:24:32 +01:00
return createErrorResponse ( ApiErrorCode . ALREADY_EXISTS , `Case " ${ name } " is already linked to ${ linkedCases [ name ] } ` );
2026-01-21 13:20:43 +01:00
}
// Save the linked case
linkedCases [ name ] = expandedPath ;
try {
const claudemanDir = join ( homedir (), '.claudeman' );
if ( ! existsSync ( claudemanDir )) {
mkdirSync ( claudemanDir , { recursive : true });
}
2026-02-18 07:06:34 +01:00
await fs . writeFile ( linkedCasesFile , JSON . stringify ( linkedCases , null , 2 ));
2026-01-21 13:20:43 +01:00
this . broadcast ( 'case:linked' , { name , path : expandedPath });
2026-02-04 07:24:32 +01:00
return { success : true , data : { case : { name , path : expandedPath } } };
2026-01-21 13:20:43 +01:00
} catch ( err ) {
2026-02-04 07:24:32 +01:00
return createErrorResponse ( ApiErrorCode . OPERATION_FAILED , getErrorMessage ( err ));
2026-01-21 13:20:43 +01:00
}
});
2026-01-18 07:31:19 +01:00
this . app . get ( '/api/cases/:name' , async ( req ) => {
const { name } = req . params as { name : string };
2026-01-21 13:20:43 +01:00
// First check linked cases
const linkedCasesFile = join ( homedir (), '.claudeman' , 'linked-cases.json' );
try {
2026-02-12 20:14:01 +01:00
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 ,
};
2026-01-21 13:20:43 +01:00
}
} catch {
2026-02-12 20:14:01 +01:00
// ENOENT or parse errors - fall through to casesDir check
2026-01-21 13:20:43 +01:00
}
// Then check casesDir
2026-01-18 07:31:19 +01:00
const casePath = join ( casesDir , name );
if ( ! existsSync ( casePath )) {
2026-01-24 04:35:58 +01:00
return createErrorResponse ( ApiErrorCode . NOT_FOUND , 'Case not found' );
2026-01-18 07:31:19 +01:00
}
return {
name ,
path : casePath ,
hasClaudeMd : existsSync ( join ( casePath , 'CLAUDE.md' )),
};
});
2026-01-18 20:11:46 +01:00
2026-01-27 11:02:02 +01:00
// 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 (), '.claudeman' , 'linked-cases.json' );
try {
2026-02-12 20:14:01 +01:00
const linkedCases : Record < string , string > = JSON . parse ( await fs . readFile ( linkedCasesFile , 'utf-8' ));
if ( linkedCases [ name ]) {
casePath = linkedCases [ name ];
2026-01-27 11:02:02 +01:00
}
} catch {
2026-02-12 20:14:01 +01:00
// ENOENT or parse errors - fall through to casesDir
2026-01-27 11:02:02 +01:00
}
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 {
2026-02-12 20:14:01 +01:00
const content = await fs . readFile ( fixPlanPath , 'utf-8' );
2026-01-27 11:02:02 +01:00
// 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 ,
});
}
}
2026-02-04 07:24:32 +01:00
// 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 };
2026-01-27 11:02:02 +01:00
return {
success : true ,
exists : true ,
content ,
todos ,
stats ,
};
} catch ( err ) {
return createErrorResponse ( ApiErrorCode . OPERATION_FAILED , `Failed to read @fix_plan.md: ${ err } ` );
}
});
2026-01-18 20:11:46 +01:00
// Quick Start: Create case (if needed) and start interactive session in one click
2026-01-18 21:36:03 +01:00
this . app . post ( '/api/quick-start' , async ( req ) : Promise < QuickStartResponse > => {
2026-01-19 11:19:13 +01:00
// Prevent unbounded session creation
if ( this . sessions . size >= MAX_CONCURRENT_SESSIONS ) {
2026-02-18 07:06:34 +01:00
return createErrorResponse ( ApiErrorCode . SESSION_BUSY , `Maximum concurrent sessions ( ${ MAX_CONCURRENT_SESSIONS } ) reached.` );
2026-01-19 11:19:13 +01:00
}
2026-02-04 07:24:32 +01:00
const result = QuickStartSchema . safeParse ( req . body );
if ( ! result . success ) {
2026-02-18 07:06:34 +01:00
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , result . error . issues [ 0 ] ? . message ?? 'Validation failed' );
2026-01-18 20:11:46 +01:00
}
2026-02-04 07:24:32 +01:00
const { caseName = 'testcase' , mode = 'claude' } = result . data ;
2026-01-18 20:11:46 +01:00
const casePath = join ( casesDir , caseName );
2026-02-04 07:24:32 +01:00
// Security: Path traversal protection - use relative path check
2026-01-21 09:54:42 +01:00
const resolvedPath = resolve ( casePath );
const resolvedBase = resolve ( casesDir );
2026-02-04 07:24:32 +01:00
const relPath = relative ( resolvedBase , resolvedPath );
if ( relPath . startsWith ( '..' ) || isAbsolute ( relPath )) {
2026-02-18 07:06:34 +01:00
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , 'Invalid case path' );
2026-01-21 09:54:42 +01:00
}
2026-01-18 20:11:46 +01:00
// 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 });
2026-01-19 02:12:32 +01:00
// Read settings to get custom template path
2026-02-12 20:14:01 +01:00
const templatePath = await this . getDefaultClaudeMdPath ();
2026-01-19 02:12:32 +01:00
const claudeMd = generateClaudeMd ( caseName , '' , templatePath );
2026-01-18 20:11:46 +01:00
writeFileSync ( join ( casePath , 'CLAUDE.md' ), claudeMd );
2026-01-24 02:46:24 +01:00
// Write .claude/settings.local.json with hooks for desktop notifications
2026-02-18 07:06:34 +01:00
await writeHooksConfig ( casePath );
2026-01-24 02:46:24 +01:00
2026-01-18 20:11:46 +01:00
this . broadcast ( 'case:created' , { name : caseName , path : casePath });
} catch ( err ) {
2026-02-18 07:06:34 +01:00
return createErrorResponse ( ApiErrorCode . OPERATION_FAILED , `Failed to create case: ${ getErrorMessage ( err ) } ` );
2026-01-18 20:11:46 +01:00
}
}
// Create a new session with the case as working directory
2026-02-18 23:14:39 +01:00
// Apply global Nice priority config and model config from settings
2026-02-12 20:14:01 +01:00
const niceConfig = await this . getGlobalNiceConfig ();
2026-02-18 23:14:39 +01:00
const qsModelConfig = await this . getModelConfig ();
const qsModel = ( mode !== 'shell' ) ? qsModelConfig?.defaultModel : undefined ;
2026-01-19 03:40:57 +01:00
const session = new Session ({
workingDir : casePath ,
2026-02-18 12:51:35 +01:00
mux : this.mux ,
useMux : true ,
2026-01-21 09:54:42 +01:00
mode : mode ,
2026-01-27 11:43:20 +01:00
niceConfig : niceConfig ,
2026-02-18 23:14:39 +01:00
model : qsModel ,
2026-01-19 03:40:57 +01:00
});
2026-01-21 19:49:44 +01:00
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
2026-01-23 22:14:00 +01:00
// so the initial state already has the phrase configured (only if globally enabled)
if ( mode === 'claude' && this . store . getConfig (). ralphEnabled ) {
2026-01-21 22:36:45 +01:00
autoConfigureRalph ( session , casePath , () => {}); // no broadcast yet
2026-01-23 22:14:00 +01:00
if ( ! session . ralphTracker . enabled ) {
session . ralphTracker . enable ();
2026-01-30 17:20:53 +01:00
session . ralphTracker . enableAutoEnable (); // Allow re-enabling on restart
2026-01-23 22:14:00 +01:00
}
2026-01-21 19:49:44 +01:00
}
2026-01-18 20:11:46 +01:00
this . sessions . set ( session . id , session );
2026-01-25 07:26:49 +01:00
this . store . incrementSessionsCreated ();
2026-01-22 20:36:06 +01:00
this . persistSessionState ( session );
2026-02-12 20:14:01 +01:00
await this . setupSessionListeners ( session );
2026-02-19 18:30:14 +01:00
getLifecycleLog (). log ({ event : 'created' , sessionId : session.id , name : session.name , reason : 'quick_start' });
2026-01-18 20:11:46 +01:00
this . broadcast ( 'session:created' , session . toDetailedState ());
2026-01-21 09:54:42 +01:00
// Start in the appropriate mode
2026-01-18 20:11:46 +01:00
try {
2026-01-21 09:54:42 +01:00
if ( mode === 'shell' ) {
await session . startShell ();
2026-02-19 18:30:14 +01:00
getLifecycleLog (). log ({ event : 'started' , sessionId : session.id , name : session.name , mode : 'shell' });
2026-01-21 09:54:42 +01:00
this . broadcast ( 'session:interactive' , { id : session.id , mode : 'shell' });
} else {
await session . startInteractive ();
2026-02-19 18:30:14 +01:00
getLifecycleLog (). log ({ event : 'started' , sessionId : session.id , name : session.name , mode : 'claude' });
2026-01-21 09:54:42 +01:00
this . broadcast ( 'session:interactive' , { id : session.id });
}
2026-01-28 05:56:39 +01:00
this . broadcast ( 'session:updated' , { session : this.getSessionStateWithRespawn ( session ) });
2026-01-18 20:11:46 +01:00
2026-01-21 12:52:38 +01:00
// Save lastUsedCase to settings for TUI/web sync
try {
const settingsFilePath = join ( homedir (), '.claudeman' , 'settings.json' );
let settings : Record < string , unknown > = {};
2026-02-12 20:14:01 +01:00
try {
settings = JSON . parse ( await fs . readFile ( settingsFilePath , 'utf-8' ));
} catch ( err ) {
if (( err as NodeJS . ErrnoException ). code !== 'ENOENT' ) throw err ;
2026-01-21 12:52:38 +01:00
}
settings . lastUsedCase = caseName ;
const dir = dirname ( settingsFilePath );
if ( ! existsSync ( dir )) {
mkdirSync ( dir , { recursive : true });
}
2026-01-27 20:24:00 +01:00
// Use async write to avoid blocking event loop
2026-02-04 07:24:32 +01:00
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 );
2026-01-27 20:24:00 +01:00
});
2026-02-04 07:24:32 +01:00
} catch ( err ) {
// Non-critical but log for debugging
console . warn ( '[Server] Failed to prepare settings update:' , err );
2026-01-21 12:52:38 +01:00
}
2026-01-18 20:11:46 +01:00
return {
success : true ,
sessionId : session.id ,
casePath ,
caseName ,
};
} catch ( err ) {
2026-01-19 11:19:13 +01:00
// Clean up session on error to prevent orphaned resources
2026-02-19 18:30:14 +01:00
await this . cleanupSession ( session . id , true , 'quick_start_error' );
2026-02-18 07:06:34 +01:00
return createErrorResponse ( ApiErrorCode . OPERATION_FAILED , getErrorMessage ( err ));
2026-01-18 20:11:46 +01:00
}
});
2026-01-19 02:08:58 +01:00
2026-01-27 20:24:00 +01:00
// Use enhanced PlanItem from orchestrator (has verification, dependencies, tracking)
type PlanItem = import ( '../plan-orchestrator.js' ). PlanItem ;
2026-01-27 01:58:02 +01:00
this . app . post ( '/api/generate-plan' , async ( req ) : Promise < ApiResponse > => {
2026-02-18 07:06:34 +01:00
const gpResult = GeneratePlanSchema . safeParse ( req . body );
if ( ! gpResult . success ) {
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , 'Invalid request body' );
}
2026-01-27 02:38:37 +01:00
const {
taskDescription ,
detailLevel = 'standard'
2026-02-18 07:06:34 +01:00
} = gpResult . data ;
2026-01-27 01:58:02 +01:00
2026-01-27 02:38:37 +01:00
// Build sophisticated prompt based on Ralph Wiggum methodology
const detailConfig = {
2026-01-27 11:02:02 +01:00
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' },
2026-01-27 02:38:37 +01:00
};
2026-01-27 11:02:02 +01:00
const levelConfig = detailConfig [ detailLevel ] || detailConfig . standard ;
2026-01-27 01:58:02 +01:00
2026-01-27 02:38:37 +01:00
const prompt = `You are an expert software architect breaking down a task into a thorough implementation plan.
## TASK TO IMPLEMENT
2026-01-27 01:58:02 +01:00
${ taskDescription }
2026-01-27 02:38:37 +01:00
## YOUR MISSION
Create a detailed, actionable implementation plan following Test-Driven Development (TDD) methodology.
Think deeply about:
2026-01-27 11:02:02 +01:00
- 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.
2026-01-27 02:38:37 +01:00
- What edge cases need handling?
2026-01-27 11:02:02 +01:00
- What's the logical order of dependencies?
2026-01-27 01:58:02 +01:00
2026-01-27 11:02:02 +01:00
## 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
2026-01-27 01:58:02 +01:00
2026-01-27 02:38:37 +01:00
Your plan MUST include these phases in order:
2026-01-27 01:58:02 +01:00
2026-01-27 02:38:37 +01:00
### 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
2026-01-27 20:24:00 +01:00
Return ONLY a JSON array. Each item MUST have:
- id: unique identifier (e.g., "P0-001", "P1-002")
2026-01-27 02:38:37 +01:00
- content: specific action (verb phrase, 15-120 chars, be descriptive!)
- priority: "P0" (critical/blocking), "P1" (required), "P2" (enhancement)
2026-01-27 20:24:00 +01:00
- 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)
2026-01-27 02:38:37 +01:00
## EXAMPLE OUTPUT
[
2026-01-27 20:24:00 +01:00
{"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"]}
2026-01-27 02:38:37 +01:00
]
## CRITICAL RULES
2026-01-27 20:24:00 +01:00
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
2026-01-27 02:38:37 +01:00
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
2026-01-27 01:58:02 +01:00
const session = new Session ({
workingDir : process.cwd (),
2026-02-18 12:51:35 +01:00
mux : this.mux ,
useMux : false , // No mux needed for one-shot
2026-01-27 01:58:02 +01:00
mode : 'claude' ,
});
2026-02-18 23:14:39 +01:00
// Use configured model for plan generation, falling back to opus
const planModelConfig = await this . getModelConfig ();
const modelToUse = planModelConfig ? . agentTypeOverrides ? . implement || planModelConfig ? . defaultModel || 'opus' ;
2026-01-27 02:38:37 +01:00
2026-01-27 01:58:02 +01:00
try {
2026-01-27 02:38:37 +01:00
const { result , cost } = await session . runPrompt ( prompt , { model : modelToUse });
2026-01-27 01:58:02 +01:00
// 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' );
}
2026-01-27 20:24:00 +01:00
// Validate and normalize items with enhanced fields
2026-01-27 01:58:02 +01:00
items = parsed . map (( item : unknown , idx : number ) => {
if ( typeof item !== 'object' || item === null ) {
2026-01-27 20:24:00 +01:00
return {
id : `task- ${ idx } ` ,
content : `Step ${ idx + 1 } ` ,
priority : null ,
verificationCriteria : 'Task completed successfully' ,
status : 'pending' as const ,
attempts : 0 ,
version : 1 ,
};
2026-01-27 01:58:02 +01:00
}
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 ;
}
2026-01-27 20:24:00 +01:00
// 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 ,
};
2026-01-27 11:02:02 +01:00
});
// No artificial limit - let Claude generate what's needed
2026-01-27 01:58:02 +01:00
} 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
}
}
});
2026-01-27 18:16:19 +01:00
// 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 > => {
2026-02-18 07:06:34 +01:00
const gpdResult = GeneratePlanDetailedSchema . safeParse ( req . body );
if ( ! gpdResult . success ) {
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , 'Invalid request body' );
2026-01-27 18:16:19 +01:00
}
2026-02-18 07:06:34 +01:00
const { taskDescription , caseName } = gpdResult . data ;
2026-01-27 18:16:19 +01:00
2026-01-28 00:09:22 +01:00
// Determine output directory for saving wizard results
let outputDir : string | undefined ;
if ( caseName ) {
const casesDir = join ( homedir (), 'claudeman-cases' );
const casePath = join ( casesDir , caseName );
2026-02-04 07:24:32 +01:00
// Security: Path traversal protection - use relative path check
2026-01-28 00:09:22 +01:00
const resolvedCase = resolve ( casePath );
const resolvedBase = resolve ( casesDir );
2026-02-04 07:24:32 +01:00
const relPath = relative ( resolvedBase , resolvedCase );
if ( ! relPath . startsWith ( '..' ) && ! isAbsolute ( relPath ) && existsSync ( casePath )) {
2026-01-28 00:09:22 +01:00
outputDir = join ( casePath , 'ralph-wizard' );
2026-01-30 11:30:51 +01:00
// 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 );
}
}
2026-01-28 00:09:22 +01:00
}
}
2026-02-18 23:14:39 +01:00
const detailedModelConfig = await this . getModelConfig ();
const orchestrator = new PlanOrchestrator ( this . mux , process . cwd (), outputDir , detailedModelConfig ?? undefined );
2026-01-27 18:16:19 +01:00
2026-01-28 01:25:25 +01:00
// 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 });
2026-01-27 22:06:09 +01:00
2026-01-27 18:16:19 +01:00
// 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 );
};
2026-01-27 22:06:09 +01:00
// 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 );
};
2026-01-27 18:16:19 +01:00
try {
const result : DetailedPlanResult = await orchestrator . generateDetailedPlan (
taskDescription ,
2026-01-27 22:06:09 +01:00
onProgress ,
onSubagent
2026-01-27 18:16:19 +01:00
);
2026-01-28 01:25:25 +01:00
// Clean up orchestrator from active map
this . activePlanOrchestrators . delete ( orchestratorId );
this . broadcast ( 'plan:completed' , { orchestratorId , success : result.success });
2026-01-27 22:06:09 +01:00
2026-01-27 18:16:19 +01:00
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 ,
2026-01-28 01:25:25 +01:00
orchestratorId ,
2026-01-27 18:16:19 +01:00
},
};
} catch ( err ) {
2026-01-28 01:25:25 +01:00
// Clean up on error too
this . activePlanOrchestrators . delete ( orchestratorId );
this . broadcast ( 'plan:completed' , { orchestratorId , success : false , error : getErrorMessage ( err ) });
2026-01-27 18:16:19 +01:00
return createErrorResponse ( ApiErrorCode . OPERATION_FAILED , 'Detailed plan generation failed: ' + getErrorMessage ( err ));
}
});
2026-01-28 01:25:25 +01:00
// Cancel active plan generation
this . app . post ( '/api/cancel-plan-generation' , async ( req ) : Promise < ApiResponse > => {
2026-02-18 07:06:34 +01:00
const cpResult = CancelPlanSchema . safeParse ( req . body );
if ( ! cpResult . success ) {
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , 'Invalid request body' );
}
const { orchestratorId } = cpResult . data ;
2026-01-28 01:25:25 +01:00
// 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 } };
});
2026-01-28 04:04:40 +01:00
// 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 (), 'claudeman-cases' );
2026-02-18 07:28:15 +01:00
let casePath = join ( casesDir , caseName );
2026-01-28 04:04:40 +01:00
2026-02-04 07:24:32 +01:00
// Security: Path traversal protection - use relative path check
2026-01-28 04:04:40 +01:00
const resolvedCase = resolve ( casePath );
const resolvedBase = resolve ( casesDir );
2026-02-04 07:24:32 +01:00
const relPath = relative ( resolvedBase , resolvedCase );
if ( relPath . startsWith ( '..' ) || isAbsolute ( relPath )) {
2026-01-28 04:04:40 +01:00
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , 'Invalid case name' );
}
2026-02-18 07:28:15 +01:00
// Check linked cases if path doesn't exist
if ( ! existsSync ( casePath )) {
const linkedCasesFile = join ( homedir (), '.claudeman' , '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
}
}
2026-01-28 04:04:40 +01:00
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
2026-01-30 11:30:51 +01:00
// Cache disabled to ensure fresh prompts when starting new plan generations
this . app . get ( '/api/cases/:caseName/ralph-wizard/file/:filePath' , async ( req , reply ) => {
2026-01-28 04:04:40 +01:00
const { caseName , filePath } = req . params as { caseName : string ; filePath : string };
const casesDir = join ( homedir (), 'claudeman-cases' );
2026-02-18 07:28:15 +01:00
let casePath = join ( casesDir , caseName );
2026-01-28 04:04:40 +01:00
2026-01-30 11:30:51 +01:00
// 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' );
2026-02-04 07:24:32 +01:00
// Security: Path traversal protection for case name - use relative path check
2026-01-28 04:04:40 +01:00
const resolvedCase = resolve ( casePath );
const resolvedBase = resolve ( casesDir );
2026-02-04 07:24:32 +01:00
const relPath = relative ( resolvedBase , resolvedCase );
if ( relPath . startsWith ( '..' ) || isAbsolute ( relPath )) {
2026-01-28 04:04:40 +01:00
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , 'Invalid case name' );
}
2026-02-18 07:28:15 +01:00
// Check linked cases if path doesn't exist
if ( ! existsSync ( casePath )) {
const linkedCasesFile = join ( homedir (), '.claudeman' , '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
}
}
2026-01-28 04:04:40 +01:00
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' );
}
2026-02-12 20:14:01 +01:00
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 ;
2026-01-28 04:04:40 +01:00
}
const isJson = filePath . endsWith ( '.json' );
2026-02-18 07:28:15 +01:00
// Parse JSON content safely (may contain invalid JSON or unescaped control characters)
2026-02-04 07:24:32 +01:00
let parsed : unknown = null ;
if ( isJson ) {
try {
parsed = JSON . parse ( content );
} catch {
2026-02-18 07:28:15 +01:00
// 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' )
// eslint-disable-next-line no-control-regex
. replace ( /[\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
}
2026-02-04 07:24:32 +01:00
}
}
2026-01-28 04:04:40 +01:00
return {
success : true ,
data : {
content ,
filePath : decodedPath ,
isJson ,
2026-02-04 07:24:32 +01:00
parsed ,
2026-01-28 04:04:40 +01:00
},
};
});
2026-01-27 20:24:00 +01:00
// ============ 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' );
}
2026-02-18 07:06:34 +01:00
const ptuResult = PlanTaskUpdateSchema . safeParse ( req . body );
if ( ! ptuResult . success ) {
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , 'Invalid request body' );
}
const update = ptuResult . data as {
2026-01-27 20:24:00 +01:00
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' );
}
2026-02-18 07:06:34 +01:00
const ptaResult = PlanTaskAddSchema . safeParse ( req . body );
if ( ! ptaResult . success ) {
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , 'Invalid request body' );
}
const task = ptaResult . data ;
2026-01-27 20:24:00 +01:00
const result = tracker . addPlanTask ( task );
this . broadcast ( 'session:planTaskAdded' , { sessionId : id , task : result.task });
return { success : true , data : result.task };
});
2026-01-19 02:08:58 +01:00
// ============ App Settings Endpoints ============
const settingsPath = join ( homedir (), '.claudeman' , 'settings.json' );
this . app . get ( '/api/settings' , async () => {
try {
2026-02-12 20:14:01 +01:00
const content = await fs . readFile ( settingsPath , 'utf-8' );
return JSON . parse ( content );
2026-01-19 02:08:58 +01:00
} catch ( err ) {
2026-02-12 20:14:01 +01:00
if (( err as NodeJS . ErrnoException ). code !== 'ENOENT' ) {
console . error ( 'Failed to read settings:' , err );
}
2026-01-19 02:08:58 +01:00
}
return {};
});
this . app . put ( '/api/settings' , async ( req ) => {
2026-02-18 07:06:34 +01:00
const settingsResult = SettingsUpdateSchema . safeParse ( req . body );
if ( ! settingsResult . success ) {
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , 'Invalid settings' );
}
const settings = settingsResult . data as Record < string , unknown >;
2026-01-19 02:08:58 +01:00
try {
const dir = dirname ( settingsPath );
if ( ! existsSync ( dir )) {
mkdirSync ( dir , { recursive : true });
}
2026-02-17 16:47:54 +01:00
let existing : Record < string , unknown > = {};
2026-02-18 07:06:34 +01:00
try { existing = JSON . parse ( await fs . readFile ( settingsPath , 'utf-8' )); } catch { /* ignore */ }
2026-02-17 16:47:54 +01:00
const merged = { ... existing , ... settings };
2026-02-18 07:06:34 +01:00
await fs . writeFile ( settingsPath , JSON . stringify ( merged , null , 2 ));
2026-01-25 18:09:25 +01:00
// 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' );
}
2026-01-28 07:07:18 +01:00
// Handle image watcher toggle dynamically
2026-02-17 16:47:54 +01:00
const imageWatcherEnabled = settings . imageWatcherEnabled ?? false ;
2026-01-28 07:07:18 +01:00
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' );
}
2026-01-19 02:08:58 +01:00
return { success : true };
} catch ( err ) {
2026-01-24 04:35:58 +01:00
return createErrorResponse ( ApiErrorCode . OPERATION_FAILED , getErrorMessage ( err ));
2026-01-19 02:08:58 +01:00
}
});
2026-01-19 02:18:51 +01:00
2026-02-16 21:19:16 +01:00
// ============ 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 ) => {
2026-02-18 07:06:34 +01:00
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 >;
2026-02-16 21:19:16 +01:00
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 });
}
2026-02-18 07:06:34 +01:00
await fs . writeFile ( settingsPath , JSON . stringify ( settings , null , 2 ));
2026-02-16 21:19:16 +01:00
return { success : true };
} catch ( err ) {
return createErrorResponse ( ApiErrorCode . OPERATION_FAILED , getErrorMessage ( err ));
}
});
2026-01-27 11:43:20 +01:00
// ============ CPU Priority Endpoints ============
2026-01-27 01:58:02 +01:00
2026-01-27 11:43:20 +01:00
// Get Nice priority config for a session
2026-01-27 01:58:02 +01:00
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 ,
2026-01-27 11:43:20 +01:00
nice : session.niceConfig ,
2026-01-27 01:58:02 +01:00
};
});
2026-01-27 11:43:20 +01:00
// Update Nice priority config for a session
2026-01-27 01:58:02 +01:00
// 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' );
}
2026-02-18 07:06:34 +01:00
const clResult = CpuLimitSchema . safeParse ( req . body );
if ( ! clResult . success ) {
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , 'Invalid request body' );
2026-01-27 01:58:02 +01:00
}
2026-02-18 07:06:34 +01:00
const body = clResult . data as Partial < NiceConfig >;
2026-01-27 01:58:02 +01:00
2026-01-27 11:43:20 +01:00
session . setNice ( body );
2026-01-27 01:58:02 +01:00
this . persistSessionState ( session );
2026-01-28 05:56:39 +01:00
this . broadcast ( 'session:updated' , { session : this.getSessionStateWithRespawn ( session ) });
2026-01-27 01:58:02 +01:00
return {
success : true ,
2026-01-27 11:43:20 +01:00
nice : session.niceConfig ,
2026-02-12 13:44:07 +01:00
note : 'Nice priority only affects newly created mux sessions, not currently running ones.' ,
2026-01-27 01:58:02 +01:00
};
});
2026-01-26 09:06:49 +01:00
// ============ Subagent Window State Endpoints ============
// Persists minimized/open window states for cross-browser sync
const windowStatesPath = join ( homedir (), '.claudeman' , 'subagent-window-states.json' );
this . app . get ( '/api/subagent-window-states' , async () => {
try {
2026-02-12 20:14:01 +01:00
const content = await fs . readFile ( windowStatesPath , 'utf-8' );
return JSON . parse ( content );
2026-01-26 09:06:49 +01:00
} catch ( err ) {
2026-02-12 20:14:01 +01:00
if (( err as NodeJS . ErrnoException ). code !== 'ENOENT' ) {
console . error ( 'Failed to read subagent window states:' , err );
}
2026-01-26 09:06:49 +01:00
}
return { minimized : {}, open : [] };
});
this . app . put ( '/api/subagent-window-states' , async ( req ) => {
2026-02-18 07:06:34 +01:00
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 >;
2026-01-26 09:06:49 +01:00
try {
const dir = dirname ( windowStatesPath );
if ( ! existsSync ( dir )) {
mkdirSync ( dir , { recursive : true });
}
2026-02-18 07:06:34 +01:00
await fs . writeFile ( windowStatesPath , JSON . stringify ( states , null , 2 ));
2026-01-26 09:06:49 +01:00
return { success : true };
} catch ( err ) {
return createErrorResponse ( ApiErrorCode . OPERATION_FAILED , getErrorMessage ( err ));
}
});
2026-01-29 14:56:11 +01:00
// ============ Subagent Parent Associations ============
// Persists which TAB each agent window connects to.
// This is the PERMANENT record of agent -> tab associations.
const parentMapPath = join ( homedir (), '.claudeman' , 'subagent-parents.json' );
this . app . get ( '/api/subagent-parents' , async () => {
try {
2026-02-12 20:14:01 +01:00
const content = await fs . readFile ( parentMapPath , 'utf-8' );
return JSON . parse ( content );
2026-01-29 14:56:11 +01:00
} catch ( err ) {
2026-02-12 20:14:01 +01:00
if (( err as NodeJS . ErrnoException ). code !== 'ENOENT' ) {
console . error ( 'Failed to read subagent parent map:' , err );
}
2026-01-29 14:56:11 +01:00
}
return {};
});
this . app . put ( '/api/subagent-parents' , async ( req ) => {
2026-02-18 07:06:34 +01:00
const spResult = SubagentParentMapSchema . safeParse ( req . body );
if ( ! spResult . success ) {
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , 'Invalid parent map' );
}
const parentMap = spResult . data ;
2026-01-29 14:56:11 +01:00
try {
const dir = dirname ( parentMapPath );
if ( ! existsSync ( dir )) {
mkdirSync ( dir , { recursive : true });
}
2026-02-18 07:06:34 +01:00
await fs . writeFile ( parentMapPath , JSON . stringify ( parentMap , null , 2 ));
2026-01-29 14:56:11 +01:00
return { success : true };
} catch ( err ) {
return createErrorResponse ( ApiErrorCode . OPERATION_FAILED , getErrorMessage ( err ));
}
});
2026-02-18 12:51:35 +01:00
// ============ Mux Session Management Endpoints ============
2026-01-19 02:18:51 +01:00
2026-02-18 12:51:35 +01:00
// Get all tracked mux sessions with stats
this . app . get ( '/api/mux-sessions' , async () => {
const sessions = await this . mux . getSessionsWithStats ();
2026-01-19 02:18:51 +01:00
return {
2026-02-18 12:51:35 +01:00
sessions ,
muxAvailable : this.mux.isAvailable ()
2026-01-19 02:18:51 +01:00
};
});
2026-02-12 13:44:07 +01:00
// Kill a mux session
2026-02-18 12:51:35 +01:00
this . app . delete ( '/api/mux-sessions/:sessionId' , async ( req ) => {
2026-01-19 02:18:51 +01:00
const { sessionId } = req . params as { sessionId : string };
2026-02-12 10:29:57 +01:00
const success = await this . mux . killSession ( sessionId );
2026-01-19 02:18:51 +01:00
return { success };
});
2026-02-18 12:51:35 +01:00
// Reconcile mux sessions (find dead ones)
this . app . post ( '/api/mux-sessions/reconcile' , async () => {
2026-02-12 10:29:57 +01:00
const result = await this . mux . reconcileSessions ();
2026-01-19 02:18:51 +01:00
return result ;
});
// Start stats collection
2026-02-18 12:51:35 +01:00
this . app . post ( '/api/mux-sessions/stats/start' , async () => {
2026-02-12 10:29:57 +01:00
this . mux . startStatsCollection ( STATS_COLLECTION_INTERVAL_MS );
2026-01-19 02:18:51 +01:00
return { success : true };
});
// Stop stats collection
2026-02-18 12:51:35 +01:00
this . app . post ( '/api/mux-sessions/stats/stop' , async () => {
2026-02-12 10:29:57 +01:00
this . mux . stopStatsCollection ();
2026-01-19 02:18:51 +01:00
return { success : true };
});
2026-01-19 06:51:25 +01:00
// System stats endpoint for frontend header display
this . app . get ( '/api/system/stats' , async () => {
return this . getSystemStats ();
});
2026-01-23 10:20:37 +01:00
2026-01-25 14:33:12 +01:00
// ========== 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 };
});
2026-01-25 17:40:28 +01:00
// 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' );
});
2026-01-27 00:41:00 +01:00
// 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 } };
});
2026-01-26 04:35:50 +01:00
2026-02-19 14:00:10 +01:00
// ========== 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 ) };
});
2026-01-24 02:46:24 +01:00
// ========== Hook Events ==========
this . app . post ( '/api/hook-event' , async ( req ) => {
2026-02-04 07:24:32 +01:00
const result = HookEventSchema . safeParse ( req . body );
if ( ! result . success ) {
return createErrorResponse ( ApiErrorCode . INVALID_INPUT , result . error . issues [ 0 ] ? . message ?? 'Validation failed' );
2026-01-24 02:46:24 +01:00
}
2026-02-04 07:24:32 +01:00
const { event , sessionId , data } = result . data ;
if ( ! this . sessions . has ( sessionId )) {
2026-01-24 02:46:24 +01:00
return createErrorResponse ( ApiErrorCode . NOT_FOUND , 'Session not found' );
}
2026-01-24 03:10:02 +01:00
2026-01-25 21:29:35 +01:00
// 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
2026-01-24 03:10:02 +01:00
controller . signalElicitation ();
2026-01-25 21:29:35 +01:00
} 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
2026-02-12 13:44:07 +01:00
if ( data && 'transcript_path' in data ) {
2026-01-25 21:29:35 +01:00
const transcriptPath = String ( data . transcript_path );
if ( transcriptPath ) {
this . startTranscriptWatcher ( sessionId , transcriptPath );
2026-01-24 03:10:02 +01:00
}
}
2026-01-24 04:35:58 +01:00
// Sanitize forwarded data: only include known safe fields, limit size
2026-02-12 13:44:07 +01:00
const safeData = sanitizeHookData ( data );
2026-01-24 04:35:58 +01:00
this . broadcast ( `hook: ${ event } ` , { sessionId , timestamp : Date.now (), ... safeData });
2026-01-25 22:05:28 +01:00
// Track in run summary
const summaryTracker = this . runSummaryTrackers . get ( sessionId );
if ( summaryTracker ) {
summaryTracker . recordHookEvent ( event , safeData );
}
2026-01-24 02:46:24 +01:00
return { success : true };
});
2026-02-16 12:10:44 +01:00
// 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 ~/.claudeman/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 );
2026-02-18 07:06:34 +01:00
await fs . writeFile ( filepath , filePart . data );
2026-02-16 12:10:44 +01:00
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 );
});
2026-01-19 06:51:25 +01:00
}
2026-01-25 21:29:35 +01:00
/**
* 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 ) {
2026-01-26 12:35:00 +01:00
watcher . removeAllListeners (); // Prevent memory leaks from attached listeners
2026-01-25 21:29:35 +01:00
watcher . stop ();
this . transcriptWatchers . delete ( sessionId );
}
}
2026-02-15 05:33:34 +01:00
/** Debounced wrapper — coalesces rapid persistSessionState calls per session */
2026-01-22 20:36:06 +01:00
private persistSessionState ( session : Session ) : void {
2026-02-15 05:33:34 +01:00
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 {
2026-01-22 20:36:06 +01:00
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 };
2026-01-25 07:42:05 +01:00
// 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 ;
2026-01-22 20:52:41 +01:00
} else {
2026-01-25 07:42:05 +01:00
// 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 ;
}
2026-01-22 20:36:06 +01:00
}
this . store . setSession ( session . id , state );
}
2026-02-12 13:44:07 +01:00
// Helper to save respawn config to mux session for persistence
2026-01-21 18:30:06 +01:00
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 ,
2026-01-23 01:25:09 +01:00
autoAcceptPrompts : config.autoAcceptPrompts ,
autoAcceptDelayMs : config.autoAcceptDelayMs ,
2026-01-25 08:25:51 +01:00
completionConfirmMs : config.completionConfirmMs ,
noOutputTimeoutMs : config.noOutputTimeoutMs ,
2026-01-24 18:48:37 +01:00
aiIdleCheckEnabled : config.aiIdleCheckEnabled ,
aiIdleCheckModel : config.aiIdleCheckModel ,
aiIdleCheckMaxContext : config.aiIdleCheckMaxContext ,
aiIdleCheckTimeoutMs : config.aiIdleCheckTimeoutMs ,
aiIdleCheckCooldownMs : config.aiIdleCheckCooldownMs ,
2026-01-25 02:55:43 +01:00
aiPlanCheckEnabled : config.aiPlanCheckEnabled ,
aiPlanCheckModel : config.aiPlanCheckModel ,
aiPlanCheckMaxContext : config.aiPlanCheckMaxContext ,
aiPlanCheckTimeoutMs : config.aiPlanCheckTimeoutMs ,
aiPlanCheckCooldownMs : config.aiPlanCheckCooldownMs ,
2026-01-21 18:30:06 +01:00
durationMinutes ,
};
2026-02-12 10:29:57 +01:00
this . mux . updateRespawnConfig ( sessionId , persistedConfig );
2026-01-21 18:30:06 +01:00
}
2026-01-19 06:51:25 +01:00
// Get system CPU and memory usage
private getSystemStats () : { cpu : number ; memory : { usedMB : number ; totalMB : number ; percent : number } } {
try {
// Memory stats
const totalMem = totalmem ();
const freeMem = freemem ();
const usedMem = totalMem - freeMem ;
// CPU load average (1 min) as percentage (rough approximation)
const load = loadavg ()[ 0 ];
2026-02-12 18:26:16 +01:00
const cpuCount = WebServer . CPU_COUNT ;
2026-01-19 06:51:25 +01:00
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 }
};
}
2026-01-18 05:14:34 +01:00
}
2026-01-19 11:19:13 +01:00
// Clean up all resources associated with a session
2026-01-24 04:35:58 +01:00
// Track sessions currently being cleaned up to prevent concurrent cleanup races
private cleaningUp : Set < string > = new Set ();
2026-02-19 18:30:14 +01:00
private async cleanupSession ( sessionId : string , killMux : boolean = true , reason? : string ) : Promise < void > {
2026-01-24 04:35:58 +01:00
// Guard against concurrent cleanup of the same session
if ( this . cleaningUp . has ( sessionId )) return ;
this . cleaningUp . add ( sessionId );
try {
2026-02-19 18:30:14 +01:00
await this . _doCleanupSession ( sessionId , killMux , reason );
2026-01-24 04:35:58 +01:00
} finally {
this . cleaningUp . delete ( sessionId );
}
}
2026-02-19 18:30:14 +01:00
private async _doCleanupSession ( sessionId : string , killMux : boolean , reason? : string ) : Promise < void > {
2026-01-19 11:19:13 +01:00
const session = this . sessions . get ( sessionId );
2026-02-19 18:30:14 +01:00
const lifecycleLog = getLifecycleLog ();
lifecycleLog . log ({
2026-02-19 22:15:36 +01:00
event : killMux ? 'deleted' : 'detached' ,
2026-02-19 18:30:14 +01:00
sessionId ,
name : session?.name ,
mode : session?.mode ,
reason : reason || 'unknown' ,
});
2026-01-19 11:19:13 +01:00
2026-01-27 18:05:03 +01:00
// Stop watching @fix_plan.md for this session
if ( session ) {
session . ralphTracker . stopWatchingFixPlan ();
}
2026-02-20 06:09:28 +01:00
// Kill all subagents spawned by this session (scoped to sessionId to avoid cross-session kills)
2026-02-18 12:51:35 +01:00
if ( session && killMux ) {
2026-01-25 17:40:28 +01:00
try {
2026-02-20 06:09:28 +01:00
await subagentWatcher . killSubagentsForSession ( session . workingDir , sessionId );
2026-01-25 17:40:28 +01:00
} catch ( err ) {
console . error ( `[Server] Failed to kill subagents for session ${ sessionId } :` , err );
}
}
2026-01-25 07:42:05 +01:00
// Stop and remove respawn controller - but save config first for restart recovery
2026-01-19 11:19:13 +01:00
const controller = this . respawnControllers . get ( sessionId );
if ( controller ) {
2026-01-25 07:42:05 +01:00
// 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 );
2026-01-19 11:19:13 +01:00
controller . stop ();
controller . removeAllListeners ();
this . respawnControllers . delete ( sessionId );
2026-01-22 19:24:36 +01:00
// Notify UI that respawn is stopped for this session
this . broadcast ( 'respawn:stopped' , { sessionId , reason : 'session_cleanup' });
2026-01-19 11:19:13 +01:00
}
// Clear respawn timer
const timerInfo = this . respawnTimers . get ( sessionId );
if ( timerInfo ) {
clearTimeout ( timerInfo . timer );
this . respawnTimers . delete ( sessionId );
}
2026-01-26 03:08:13 +01:00
// Clear pending respawn start timer (from restoration grace period)
const pendingStart = this . pendingRespawnStarts . get ( sessionId );
if ( pendingStart ) {
clearTimeout ( pendingStart );
this . pendingRespawnStarts . delete ( sessionId );
}
2026-01-25 21:29:35 +01:00
// Stop transcript watcher
this . stopTranscriptWatcher ( sessionId );
2026-01-25 22:05:28 +01:00
// Stop and remove run summary tracker
const summaryTracker = this . runSummaryTrackers . get ( sessionId );
if ( summaryTracker ) {
summaryTracker . recordSessionStopped ();
summaryTracker . stop ();
this . runSummaryTrackers . delete ( sessionId );
}
2026-01-24 04:35:58 +01:00
// Clear batches and pending state updates
2026-01-19 11:19:13 +01:00
this . terminalBatches . delete ( sessionId );
2026-02-19 18:30:14 +01:00
this . terminalBatchSizes . delete ( sessionId );
2026-01-19 11:19:13 +01:00
this . taskUpdateBatches . delete ( sessionId );
2026-01-24 04:35:58 +01:00
this . stateUpdatePending . delete ( sessionId );
2026-02-04 07:24:32 +01:00
this . lastTerminalEventTime . delete ( sessionId );
this . adaptiveBatchIntervals . delete ( sessionId );
2026-01-19 11:19:13 +01:00
2026-01-21 22:36:45 +01:00
// Reset Ralph tracker on the session before cleanup
2026-01-21 04:34:45 +01:00
if ( session ) {
2026-01-21 22:36:45 +01:00
session . ralphTracker . fullReset ();
2026-01-21 04:34:45 +01:00
}
2026-01-21 22:36:45 +01:00
// Clear Ralph state from store
this . store . removeRalphState ( sessionId );
2026-01-19 13:21:01 +01:00
2026-01-21 22:36:45 +01:00
// Broadcast Ralph cleared to update UI
this . broadcast ( 'session:ralphLoopUpdate' , {
2026-01-21 04:34:45 +01:00
sessionId ,
state : { enabled : false , active : false , completionPhrase : null , startedAt : null , cycleCount : 0 , maxIterations : null , lastActivity : Date.now (), elapsedHours : null }
});
2026-01-21 22:36:45 +01:00
this . broadcast ( 'session:ralphTodoUpdate' , {
2026-01-21 04:34:45 +01:00
sessionId ,
todos : [],
stats : { total : 0 , pending : 0 , inProgress : 0 , completed : 0 }
});
2026-01-19 11:19:13 +01:00
// Stop session and remove listeners
if ( session ) {
2026-01-25 07:26:49 +01:00
// Accumulate tokens to global stats before removing session
// This preserves lifetime usage even after sessions are deleted
2026-02-18 12:51:35 +01:00
if ( killMux && ( session . inputTokens > 0 || session . outputTokens > 0 || session . totalCost > 0 )) {
2026-01-25 07:26:49 +01:00
this . store . addToGlobalStats ( session . inputTokens , session . outputTokens , session . totalCost );
2026-01-25 20:36:04 +01:00
// 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 ) {
2026-01-26 12:22:05 +01:00
this . store . recordDailyUsage ( deltaInput , deltaOutput , sessionId );
2026-01-25 20:36:04 +01:00
}
this . lastRecordedTokens . delete ( sessionId );
2026-01-25 07:26:49 +01:00
console . log ( `[Server] Added to global stats: ${ session . inputTokens + session . outputTokens } tokens, $ ${ session . totalCost . toFixed ( 4 ) } from session ${ sessionId } ` );
}
2026-01-31 23:47:49 +01:00
// 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 ( '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 );
}
2026-01-19 11:19:13 +01:00
session . removeAllListeners ();
2026-01-26 00:55:29 +01:00
// Close any active file streams for this session
fileStreamManager . closeSessionStreams ( sessionId );
2026-01-28 02:31:58 +01:00
// Stop watching for images in this session's directory
imageWatcher . unwatchSession ( sessionId );
2026-02-18 12:51:35 +01:00
await session . stop ( killMux );
2026-01-19 11:19:13 +01:00
this . sessions . delete ( sessionId );
2026-02-18 12:51:35 +01:00
// Only remove from state.json if we're also killing the mux session.
// When killMux=false (server shutdown), preserve state for recovery.
if ( killMux ) {
2026-01-22 21:22:07 +01:00
this . store . removeSession ( sessionId );
}
2026-01-19 11:19:13 +01:00
}
this . broadcast ( 'session:deleted' , { id : sessionId });
}
2026-02-12 20:14:01 +01:00
private async setupSessionListeners ( session : Session ) : Promise < void > {
2026-01-25 22:05:28 +01:00
// 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 );
2026-01-27 18:05:03 +01:00
// Set working directory for Ralph tracker to auto-load @fix_plan.md
session . ralphTracker . setWorkingDir ( session . workingDir );
2026-01-28 07:07:18 +01:00
// Start watching for new images in this session's working directory (if enabled globally and per-session)
2026-02-12 20:14:01 +01:00
if ( await this . isImageWatcherEnabled () && session . imageWatcherEnabled ) {
2026-01-28 07:07:18 +01:00
imageWatcher . watchSession ( session . id , session . workingDir );
}
2026-01-28 02:31:58 +01:00
2026-01-31 23:47:49 +01:00
// 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 );
},
2026-01-18 07:31:19 +01:00
2026-01-31 23:47:49 +01:00
clearTerminal : () => {
2026-02-18 12:51:35 +01:00
// Tell clients to clear their terminal (after mux attach)
2026-01-31 23:47:49 +01:00
this . broadcast ( 'session:clearTerminal' , { id : session.id });
},
2026-01-19 07:33:33 +01:00
2026-01-31 23:47:49 +01:00
message : ( msg : ClaudeMessage ) => {
this . broadcast ( 'session:message' , { id : session.id , message : msg });
},
2026-01-18 05:14:34 +01:00
2026-01-31 23:47:49 +01:00
error : ( error ) => {
this . broadcast ( 'session:error' , { id : session.id , error });
// Track in run summary
const tracker = this . runSummaryTrackers . get ( session . id );
if ( tracker ) tracker . recordError ( 'Session error' , String ( error ));
},
2026-01-18 05:14:34 +01:00
2026-01-31 23:47:49 +01:00
completion : ( result , cost ) => {
this . broadcast ( 'session:completion' , { id : session.id , result , cost });
2026-01-28 05:56:39 +01:00
this . broadcast ( 'session:updated' , this . getSessionStateWithRespawn ( session ));
2026-01-22 20:36:06 +01:00
this . persistSessionState ( session );
2026-01-31 23:47:49 +01:00
// 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 );
},
2026-01-18 07:31:19 +01:00
2026-01-31 23:47:49 +01:00
exit : ( code ) => {
2026-02-19 18:30:14 +01:00
getLifecycleLog (). log ({ event : 'exit' , sessionId : session.id , name : session.name , exitCode : code });
2026-01-31 23:47:49 +01:00
// 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 );
2026-01-21 10:05:39 +01:00
}
2026-01-31 23:47:49 +01:00
// 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 );
2026-01-26 12:35:00 +01:00
}
2026-01-31 23:47:49 +01:00
},
2026-01-18 07:31:19 +01:00
2026-01-31 23:47:49 +01:00
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 );
}
},
2026-01-18 07:31:19 +01:00
2026-01-31 23:47:49 +01:00
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 );
}
},
2026-01-19 00:38:11 +01:00
2026-01-31 23:47:49 +01:00
// 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 );
},
2026-01-19 00:38:11 +01:00
2026-01-31 23:47:49 +01:00
taskUpdated : ( task : BackgroundTask ) => {
// Use batching for better performance at high update rates
this . batchTaskUpdate ( session . id , task );
},
2026-01-19 00:38:11 +01:00
2026-01-31 23:47:49 +01:00
taskCompleted : ( task : BackgroundTask ) => {
this . broadcast ( 'task:completed' , { sessionId : session.id , task });
this . broadcastSessionStateDebounced ( session . id );
},
2026-01-19 00:38:11 +01:00
2026-01-31 23:47:49 +01:00
taskFailed : ( task : BackgroundTask , error : string ) => {
this . broadcast ( 'task:failed' , { sessionId : session.id , task , error });
this . broadcastSessionStateDebounced ( session . id );
},
2026-01-19 00:38:11 +01:00
2026-01-31 23:47:49 +01:00
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 );
},
2026-01-19 13:08:53 +01:00
2026-01-31 23:47:49 +01:00
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 );
},
2026-01-19 13:21:01 +01:00
2026-01-31 23:47:49 +01:00
// 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 );
},
2026-01-31 04:44:46 +01:00
2026-01-31 23:47:49 +01:00
// Ralph tracking events
ralphLoopUpdate : ( state : RalphTrackerState ) => {
this . broadcast ( 'session:ralphLoopUpdate' , { sessionId : session.id , state });
// Persist Ralph state
this . store . updateRalphState ( session . id , { loop : state });
},
2026-01-19 13:21:01 +01:00
2026-01-31 23:47:49 +01:00
ralphTodoUpdate : ( todos : RalphTodoItem []) => {
this . broadcast ( 'session:ralphTodoUpdate' , { sessionId : session.id , todos });
// Persist Ralph state
this . store . updateRalphState ( session . id , { todos });
},
2026-01-19 13:21:01 +01:00
2026-01-31 23:47:49 +01:00
ralphCompletionDetected : ( phrase : string ) => {
this . broadcast ( 'session:ralphCompletionDetected' , { sessionId : session.id , phrase });
// Track in run summary
const tracker = this . runSummaryTrackers . get ( session . id );
if ( tracker ) tracker . recordRalphCompletion ( phrase );
},
2026-01-23 10:20:37 +01:00
2026-01-31 23:47:49 +01:00
// 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 } `
);
}
},
2026-01-27 00:29:38 +01:00
2026-01-31 23:47:49 +01:00
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
);
}
},
2026-01-27 00:29:38 +01:00
2026-01-31 23:47:49 +01:00
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 } `
);
}
},
2026-01-27 00:29:38 +01:00
2026-01-31 23:47:49 +01:00
// Bash tool tracking events (for clickable file paths)
bashToolStart : ( tool : ActiveBashTool ) => {
this . broadcast ( 'session:bashToolStart' , { sessionId : session.id , tool });
},
2026-01-26 00:55:29 +01:00
2026-01-31 23:47:49 +01:00
bashToolEnd : ( tool : ActiveBashTool ) => {
this . broadcast ( 'session:bashToolEnd' , { sessionId : session.id , tool });
},
2026-01-26 00:55:29 +01:00
2026-01-31 23:47:49 +01:00
bashToolsUpdate : ( tools : ActiveBashTool []) => {
this . broadcast ( 'session:bashToolsUpdate' , { sessionId : session.id , tools });
},
};
2026-01-26 00:55:29 +01:00
2026-01-31 23:47:49 +01:00
// 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 ( '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 );
2026-01-18 07:31:19 +01:00
}
private setupRespawnListeners ( sessionId : string , controller : RespawnController ) : void {
2026-02-19 14:00:10 +01:00
// Wire team watcher for team-aware idle detection
controller . setTeamWatcher ( this . teamWatcher );
2026-01-25 22:28:30 +01:00
// Helper to get tracker lazily (may not exist at setup time for restored sessions)
const getTracker = () => this . runSummaryTrackers . get ( sessionId );
2026-01-25 22:05:28 +01:00
2026-01-18 07:31:19 +01:00
controller . on ( 'stateChanged' , ( state : RespawnState , prevState : RespawnState ) => {
this . broadcast ( 'respawn:stateChanged' , { sessionId , state , prevState });
2026-01-25 22:28:30 +01:00
// Track in run summary (lazy lookup since tracker may be created after controller)
const tracker = getTracker ();
if ( tracker ) tracker . recordStateChange ( state , ` ${ prevState } → ${ state } ` );
2026-01-18 07:31:19 +01:00
});
controller . on ( 'respawnCycleStarted' , ( cycleNumber : number ) => {
this . broadcast ( 'respawn:cycleStarted' , { sessionId , cycleNumber });
});
controller . on ( 'respawnCycleCompleted' , ( cycleNumber : number ) => {
this . broadcast ( 'respawn:cycleCompleted' , { sessionId , cycleNumber });
});
2026-01-27 00:54:02 +01:00
controller . on ( 'respawnBlocked' , ( data : { reason : string ; details : string }) => {
this . broadcast ( 'respawn:blocked' , { sessionId , reason : data.reason , details : data.details });
// Track in run summary (lazy lookup)
const tracker = getTracker ();
if ( tracker ) tracker . recordWarning ( `Respawn blocked: ${ data . reason } ` , data . details );
});
2026-01-18 07:31:19 +01:00
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 });
});
2026-01-22 19:24:36 +01:00
controller . on ( 'detectionUpdate' , ( detection : unknown ) => {
this . broadcast ( 'respawn:detectionUpdate' , { sessionId , detection });
});
2026-01-23 01:25:09 +01:00
controller . on ( 'autoAcceptSent' , () => {
this . broadcast ( 'respawn:autoAcceptSent' , { sessionId });
});
2026-01-24 18:48:37 +01:00
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 });
2026-01-25 22:28:30 +01:00
// Track in run summary (lazy lookup)
const tracker = getTracker ();
if ( tracker ) tracker . recordAiCheckResult ( result . verdict );
2026-01-24 18:48:37 +01:00
});
controller . on ( 'aiCheckFailed' , ( error : string ) => {
this . broadcast ( 'respawn:aiCheckFailed' , { sessionId , error });
2026-01-25 22:28:30 +01:00
// Track in run summary (lazy lookup)
const tracker = getTracker ();
if ( tracker ) tracker . recordError ( 'AI check failed' , error );
2026-01-24 18:48:37 +01:00
});
controller . on ( 'aiCheckCooldown' , ( active : boolean , endsAt : number | null ) => {
this . broadcast ( 'respawn:aiCheckCooldown' , { sessionId , active , endsAt });
});
2026-01-25 02:55:43 +01:00
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 });
});
2026-01-25 03:57:44 +01:00
// 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 });
});
2026-01-25 02:55:43 +01:00
2026-01-18 07:31:19 +01:00
controller . on ( 'log' , ( message : string ) => {
this . broadcast ( 'respawn:log' , { sessionId , message });
});
controller . on ( 'error' , ( error : Error ) => {
this . broadcast ( 'respawn:error' , { sessionId , error : error.message });
2026-01-25 22:28:30 +01:00
// Track in run summary (lazy lookup)
const tracker = getTracker ();
if ( tracker ) tracker . recordError ( 'Respawn error' , error . message );
2026-01-18 05:14:34 +01:00
});
}
2026-01-19 00:38:11 +01:00
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 ();
2026-01-30 11:39:15 +01:00
controller . removeAllListeners ();
this . respawnControllers . delete ( sessionId );
2026-01-19 00:38:11 +01:00
this . broadcast ( 'respawn:stopped' , { sessionId , reason : 'duration_expired' });
}
this . respawnTimers . delete ( sessionId );
2026-01-22 20:36:06 +01:00
// Update persisted state (respawn no longer active)
const session = this . sessions . get ( sessionId );
if ( session ) {
this . persistSessionState ( session );
}
2026-01-19 00:38:11 +01:00
}, durationMinutes * 60 * 1000 );
this . respawnTimers . set ( sessionId , { timer , endAt , startedAt : now });
this . broadcast ( 'respawn:timerStarted' , { sessionId , durationMinutes , endAt , startedAt : now });
}
2026-01-26 03:08:13 +01:00
2026-01-25 08:25:51 +01:00
/**
* Restore a RespawnController from persisted configuration.
2026-01-26 03:08:13 +01:00
* Creates the controller, sets up listeners, but does NOT start it.
2026-01-25 08:25:51 +01:00
*
* @param session - The session to attach the controller to
* @param config - The persisted respawn configuration
2026-02-18 12:51:35 +01:00
* @param source - Source of the config for logging (e.g., 'state.json' or 'mux-sessions.json')
2026-01-25 08:25:51 +01:00
*/
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 );
2026-01-26 03:08:13 +01:00
// 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 );
2026-02-04 07:24:32 +01:00
// 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 ;
}
2026-01-26 03:08:13 +01:00
// 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)` );
}
2026-01-25 08:25:51 +01:00
if ( config . durationMinutes && config . durationMinutes > 0 ) {
this . setupTimedRespawn ( session . id , config . durationMinutes );
}
}
2026-01-19 02:12:32 +01:00
// Helper to get custom CLAUDE.md template path from settings
2026-02-12 20:14:01 +01:00
private async getDefaultClaudeMdPath () : Promise < string | undefined > {
2026-01-19 02:12:32 +01:00
const settingsPath = join ( homedir (), '.claudeman' , 'settings.json' );
2026-01-19 10:36:30 +01:00
2026-01-19 02:12:32 +01:00
try {
2026-02-12 20:14:01 +01:00
const content = await fs . readFile ( settingsPath , 'utf-8' );
const settings = JSON . parse ( content );
if ( settings . defaultClaudeMdPath ) {
return settings . defaultClaudeMdPath ;
2026-01-19 10:36:30 +01:00
}
2026-01-19 02:12:32 +01:00
} catch ( err ) {
2026-02-12 20:14:01 +01:00
if (( err as NodeJS . ErrnoException ). code !== 'ENOENT' ) {
console . error ( 'Failed to read settings:' , err );
}
2026-01-19 02:12:32 +01:00
}
return undefined ;
}
2026-02-20 04:30:02 +01:00
// Read ~/.claudeman/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 ;
}
2026-01-27 01:58:02 +01:00
const settingsPath = join ( homedir (), '.claudeman' , 'settings.json' );
try {
2026-02-12 20:14:01 +01:00
const content = await fs . readFile ( settingsPath , 'utf-8' );
2026-02-20 04:30:02 +01:00
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 ,
};
2026-01-27 01:58:02 +01:00
}
return undefined ;
}
2026-02-18 23:14:39 +01:00
// Helper to get model configuration from settings
private async getModelConfig () : Promise < { defaultModel? : string ; agentTypeOverrides? : Record < string , string > } | null > {
2026-02-20 04:30:02 +01:00
const settings = await this . readSettings ();
return ( settings . modelConfig as { defaultModel? : string ; agentTypeOverrides? : Record < string , string > }) || null ;
2026-02-18 23:14:39 +01:00
}
2026-01-18 05:14:34 +01:00
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 );
2026-02-04 07:24:32 +01:00
// 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' });
}
});
2026-01-18 05:14:34 +01:00
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' ) {
2026-01-19 11:19:13 +01:00
// Check session limit before creating new session
if ( this . sessions . size >= MAX_CONCURRENT_SESSIONS ) {
addLog ( `Waiting: maximum concurrent sessions ( ${ MAX_CONCURRENT_SESSIONS } ) reached` );
2026-01-26 14:27:21 +01:00
await new Promise ( r => setTimeout ( r , SESSION_LIMIT_WAIT_MS ));
2026-01-19 11:19:13 +01:00
continue ;
}
2026-01-19 06:51:25 +01:00
let session : Session | null = null ;
2026-01-18 05:14:34 +01:00
try {
// Create a session for this iteration
2026-01-19 06:51:25 +01:00
session = new Session ({ workingDir : run.workingDir });
2026-01-18 05:14:34 +01:00
this . sessions . set ( session . id , session );
2026-01-25 07:26:49 +01:00
this . store . incrementSessionsCreated ();
2026-01-22 20:36:06 +01:00
this . persistSessionState ( session );
2026-02-12 20:14:01 +01:00
await this . setupSessionListeners ( session );
2026-01-18 05:14:34 +01:00
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 );
2026-01-19 06:51:25 +01:00
// Clean up the session after iteration to prevent memory leaks
2026-02-19 18:30:14 +01:00
await this . cleanupSession ( session . id , true , 'scheduled_run' );
2026-01-19 06:51:25 +01:00
run . sessionId = null ;
2026-01-18 05:14:34 +01:00
// Small pause between iterations
2026-01-26 14:27:21 +01:00
await new Promise ( r => setTimeout ( r , ITERATION_PAUSE_MS ));
2026-01-18 05:14:34 +01:00
} catch ( err ) {
2026-01-21 14:07:04 +01:00
addLog ( `Error: ${ getErrorMessage ( err ) } ` );
2026-01-18 05:14:34 +01:00
this . broadcast ( 'scheduled:updated' , run );
2026-01-19 06:51:25 +01:00
// Clean up the session on error too
if ( session ) {
try {
2026-02-19 18:30:14 +01:00
await this . cleanupSession ( session . id , true , 'scheduled_run_error' );
2026-01-19 06:51:25 +01:00
} catch {
// Ignore cleanup errors
}
run . sessionId = null ;
}
2026-01-18 05:14:34 +01:00
// Continue despite errors
2026-01-26 14:27:21 +01:00
await new Promise ( r => setTimeout ( r , SESSION_LIMIT_WAIT_MS ));
2026-01-18 05:14:34 +01:00
}
}
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` );
2026-01-21 09:54:42 +01:00
// Use cleanupSession for proper resource cleanup (listeners, respawn, etc.)
if ( run . sessionId && this . sessions . has ( run . sessionId )) {
2026-02-19 18:30:14 +01:00
await this . cleanupSession ( run . sessionId , true , 'scheduled_run_stopped' );
2026-01-21 09:54:42 +01:00
run . sessionId = null ;
2026-01-18 05:14:34 +01:00
}
this . broadcast ( 'scheduled:stopped' , run );
}
2026-01-28 05:56:39 +01:00
/**
* 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 {
2026-02-13 21:07:49 +01:00
... session . toLightDetailedState (),
2026-01-28 05:56:39 +01:00
respawnEnabled : controller?.getConfig () ? . enabled ?? false ,
respawnConfig : controller?.getConfig () ?? null ,
respawn : controller?.getStatus () ?? null ,
};
}
2026-01-26 21:27:08 +01:00
/**
* 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() {
2026-02-15 05:33:34 +01:00
const now = Date . now ();
if ( this . cachedSessionsList && ( now - this . cachedSessionsList . timestamp ) < SESSIONS_LIST_CACHE_TTL ) {
return this . cachedSessionsList . data ;
}
2026-02-13 21:07:49 +01:00
// getSessionStateWithRespawn already uses toLightDetailedState() which
// excludes terminalBuffer and textOutput — no extra stripping needed
2026-02-15 05:33:34 +01:00
const data = Array . from ( this . sessions . values ()). map ( s => this . getSessionStateWithRespawn ( s ));
this . cachedSessionsList = { data , timestamp : now };
return data ;
2026-01-26 21:27:08 +01:00
}
2026-01-19 06:51:25 +01:00
// 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)` );
}
}
2026-01-27 11:02:02 +01:00
/**
* 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 ());
2026-02-19 18:30:14 +01:00
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 ;
2026-01-27 11:02:02 +01:00
}
2026-01-26 21:27:08 +01:00
/**
* Get lightweight state for SSE init - excludes full terminal buffers
* to prevent browser freezes. Terminal buffers are fetched on-demand.
*/
private getLightState() {
2026-02-13 07:42:50 +01:00
const now = Date . now ();
if ( this . cachedLightState && now - this . cachedLightState . timestamp < WebServer . LIGHT_STATE_CACHE_TTL_MS ) {
return this . cachedLightState . data ;
}
2026-01-26 21:27:08 +01:00
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 ,
};
}
2026-02-13 07:42:50 +01:00
const result = {
2026-01-26 21:27:08 +01:00
version : APP_VERSION ,
sessions : this.getLightSessionsState (),
scheduledRuns : Array.from ( this . scheduledRuns . values ()),
respawnStatus ,
globalStats : this.store.getAggregateStats ( activeSessionTokens ),
2026-01-29 12:04:14 +01:00
subagents : subagentWatcher.getRecentSubagents ( 15 ), // 15 min to avoid stale agents
2026-02-13 07:42:50 +01:00
timestamp : now ,
2026-01-26 21:27:08 +01:00
};
2026-02-13 07:42:50 +01:00
this . cachedLightState = { data : result , timestamp : now };
return result ;
2026-01-26 21:27:08 +01:00
}
2026-01-18 05:14:34 +01:00
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 );
}
}
2026-01-19 11:48:02 +01:00
// Optimized: send pre-formatted SSE message to a client
2026-02-12 18:26:16 +01:00
// Returns false if client is backpressured or dead
2026-01-19 11:48:02 +01:00
private sendSSEPreformatted ( reply : FastifyReply , message : string ) : void {
2026-02-18 21:53:34 +01:00
// Skip backpressured clients to prevent unbounded memory growth.
// Terminal data dropped here is recovered via session:needsRefresh on drain.
2026-02-12 18:26:16 +01:00
if ( this . backpressuredClients . has ( reply )) return ;
2026-01-19 11:48:02 +01:00
try {
2026-02-12 18:26:16 +01:00
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 );
2026-02-18 21:53:34 +01:00
// 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 */ }
2026-02-12 18:26:16 +01:00
});
}
2026-01-19 11:48:02 +01:00
} catch {
this . sseClients . delete ( reply );
2026-02-12 18:26:16 +01:00
this . backpressuredClients . delete ( reply );
2026-01-19 11:48:02 +01:00
}
}
2026-01-18 05:14:34 +01:00
private broadcast ( event : string , data : unknown ) : void {
2026-02-19 18:30:14 +01:00
// 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'
) {
2026-02-13 07:42:50 +01:00
this . cachedLightState = null ;
2026-02-15 05:33:34 +01:00
this . cachedSessionsList = null ;
2026-02-13 07:42:50 +01:00
}
2026-01-19 11:48:02 +01:00
// Performance optimization: serialize JSON once for all clients
2026-01-24 04:35:58 +01:00
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 ;
}
2026-01-18 05:14:34 +01:00
for ( const client of this . sseClients ) {
2026-01-19 11:48:02 +01:00
this . sendSSEPreformatted ( client , message );
2026-01-18 05:14:34 +01:00
}
}
2026-01-18 21:36:03 +01:00
// Batch terminal data for better performance (60fps)
2026-02-02 04:49:15 +01:00
// Uses adaptive batching: extends batch window when events are rapid-fire
2026-01-18 21:36:03 +01:00
private batchTerminalData ( sessionId : string , data : string ) : void {
2026-01-22 00:08:32 +01:00
// Skip if server is stopping
if ( this . _isStopping ) return ;
2026-02-18 07:06:34 +01:00
let chunks = this . terminalBatches . get ( sessionId );
if ( ! chunks ) {
chunks = [];
this . terminalBatches . set ( sessionId , chunks );
}
chunks . push ( data );
2026-02-19 18:30:14 +01:00
const prevSize = this . terminalBatchSizes . get ( sessionId ) ?? 0 ;
const totalLength = prevSize + data . length ;
this . terminalBatchSizes . set ( sessionId , totalLength );
2026-01-18 21:36:03 +01:00
2026-02-04 07:24:32 +01:00
// Adaptive batching: detect rapid events and extend batch window (per-session)
2026-02-02 04:49:15 +01:00
const now = Date . now ();
2026-02-04 07:24:32 +01:00
const lastEvent = this . lastTerminalEventTime . get ( sessionId ) ?? 0 ;
2026-02-02 04:49:15 +01:00
const eventGap = now - lastEvent ;
this . lastTerminalEventTime . set ( sessionId , now );
2026-02-04 07:24:32 +01:00
// Adjust batch interval based on event frequency (per-session)
2026-02-02 04:49:15 +01:00
// Rapid events (<10ms gap) = 50ms batch, moderate (<20ms) = 32ms, else 16ms
2026-02-04 07:24:32 +01:00
let sessionInterval : number ;
2026-02-02 04:49:15 +01:00
if ( eventGap > 0 && eventGap < 10 ) {
2026-02-04 07:24:32 +01:00
sessionInterval = 50 ;
2026-02-02 04:49:15 +01:00
} else if ( eventGap > 0 && eventGap < 20 ) {
2026-02-04 07:24:32 +01:00
sessionInterval = 32 ;
2026-02-02 04:49:15 +01:00
} else {
2026-02-04 07:24:32 +01:00
sessionInterval = TERMINAL_BATCH_INTERVAL ;
2026-02-02 04:49:15 +01:00
}
2026-02-04 07:24:32 +01:00
this . adaptiveBatchIntervals . set ( sessionId , sessionInterval );
2026-02-12 20:14:01 +01:00
// Track minimum to avoid O(n) spread on every batch event
if ( sessionInterval < this . _minBatchInterval ) {
this . _minBatchInterval = sessionInterval ;
}
2026-02-02 04:49:15 +01:00
2026-01-26 14:27:21 +01:00
// Flush immediately if batch is large for responsiveness
2026-02-18 07:06:34 +01:00
if ( totalLength > BATCH_FLUSH_THRESHOLD ) {
2026-01-21 05:17:35 +01:00
if ( this . terminalBatchTimer ) {
clearTimeout ( this . terminalBatchTimer );
this . terminalBatchTimer = null ;
}
this . flushTerminalBatches ();
return ;
}
2026-02-02 04:49:15 +01:00
// Start batch timer if not already running (uses adaptive interval)
2026-02-04 07:24:32 +01:00
// Pick the minimum interval across all pending sessions for responsiveness
2026-01-18 21:36:03 +01:00
if ( ! this . terminalBatchTimer ) {
this . terminalBatchTimer = setTimeout (() => {
this . flushTerminalBatches ();
this . terminalBatchTimer = null ;
2026-02-04 07:24:32 +01:00
// Clear per-session intervals after flush (they'll be recalculated on next event)
this . adaptiveBatchIntervals . clear ();
2026-02-12 20:14:01 +01:00
this . _minBatchInterval = TERMINAL_BATCH_INTERVAL ;
}, this . _minBatchInterval );
2026-01-18 21:36:03 +01:00
}
}
private flushTerminalBatches () : void {
2026-02-04 07:24:32 +01:00
// Skip if server is stopping (timer may have been queued before stop() was called)
if ( this . _isStopping ) {
this . terminalBatches . clear ();
2026-02-19 18:30:14 +01:00
this . terminalBatchSizes . clear ();
2026-02-04 07:24:32 +01:00
return ;
}
2026-02-18 07:06:34 +01:00
for ( const [ sessionId , chunks ] of this . terminalBatches ) {
if ( chunks . length > 0 ) {
// Join chunks only at flush time (avoids O(n^2) string concatenation in batchTerminalData)
const data = chunks . join ( '' );
2026-01-25 22:05:28 +01:00
// 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 ;
this . broadcast ( 'session:terminal' , { id : sessionId , data : syncData });
2026-01-18 21:36:03 +01:00
}
}
this . terminalBatches . clear ();
2026-02-19 18:30:14 +01:00
this . terminalBatchSizes . clear ();
2026-01-19 06:51:25 +01:00
}
2026-02-04 07:24:32 +01:00
// Batch task:updated events at 100ms - only send latest update per task
// Key is sessionId:taskId to avoid collisions when multiple tasks update concurrently
2026-01-19 06:51:25 +01:00
private batchTaskUpdate ( sessionId : string , task : BackgroundTask ) : void {
2026-01-22 00:08:32 +01:00
// Skip if server is stopping
if ( this . _isStopping ) return ;
2026-02-04 07:24:32 +01:00
// 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 });
2026-01-19 06:51:25 +01:00
if ( ! this . taskUpdateBatchTimer ) {
this . taskUpdateBatchTimer = setTimeout (() => {
this . flushTaskUpdateBatches ();
this . taskUpdateBatchTimer = null ;
}, TASK_UPDATE_BATCH_INTERVAL );
}
}
private flushTaskUpdateBatches () : void {
2026-02-04 07:24:32 +01:00
// 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 ) {
2026-01-19 06:51:25 +01:00
this . broadcast ( 'task:updated' , { sessionId , task });
}
this . taskUpdateBatches . clear ();
}
2026-01-21 05:53:59 +01:00
/**
* 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 {
2026-01-22 00:08:32 +01:00
// Skip if server is stopping
if ( this . _isStopping ) return ;
2026-01-21 05:53:59 +01:00
this . stateUpdatePending . add ( sessionId );
if ( ! this . stateUpdateTimer ) {
this . stateUpdateTimer = setTimeout (() => {
this . flushStateUpdates ();
this . stateUpdateTimer = null ;
}, STATE_UPDATE_DEBOUNCE_INTERVAL );
}
}
private flushStateUpdates () : void {
2026-02-04 07:24:32 +01:00
// Skip if server is stopping (timer may have been queued before stop() was called)
if ( this . _isStopping ) {
this . stateUpdatePending . clear ();
return ;
}
2026-01-21 05:53:59 +01:00
for ( const sessionId of this . stateUpdatePending ) {
const session = this . sessions . get ( sessionId );
if ( session ) {
// Single expensive serialization per batch interval
2026-01-28 05:56:39 +01:00
this . broadcast ( 'session:updated' , this . getSessionStateWithRespawn ( session ));
2026-01-21 05:53:59 +01:00
}
}
this . stateUpdatePending . clear ();
}
2026-01-22 00:08:32 +01:00
/**
2026-01-24 04:35:58 +01:00
* 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.
2026-01-22 00:08:32 +01:00
*/
private cleanupDeadSSEClients () : void {
const deadClients : FastifyReply [] = [];
for ( const client of this . sseClients ) {
try {
// Check if the underlying socket is still writable
2026-02-12 13:44:07 +01:00
const socket = client . raw . socket ;
2026-01-22 00:08:32 +01:00
if ( ! socket || socket . destroyed || ! socket . writable ) {
deadClients . push ( client );
2026-01-24 04:35:58 +01:00
} else {
// Send SSE comment as keep-alive (comments start with ':')
client . raw . write ( ':keepalive\n\n' );
2026-01-22 00:08:32 +01:00
}
} catch {
// Error accessing socket means client is dead
deadClients . push ( client );
}
}
// Remove dead clients
for ( const client of deadClients ) {
this . sseClients . delete ( client );
2026-02-12 18:26:16 +01:00
this . backpressuredClients . delete ( client );
2026-01-22 00:08:32 +01:00
}
if ( deadClients . length > 0 ) {
console . log ( `[Server] Cleaned up ${ deadClients . length } dead SSE client(s)` );
}
}
2026-01-25 20:36:04 +01:00
/**
* 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 ) {
2026-01-26 12:22:05 +01:00
this . store . recordDailyUsage ( deltaInput , deltaOutput , sessionId );
2026-01-25 20:36:04 +01:00
this . lastRecordedTokens . set ( sessionId , {
input : session.inputTokens ,
output : session.outputTokens ,
});
}
}
}
2026-01-18 05:14:34 +01:00
async start () : Promise < void > {
await this . setupRoutes ();
2026-01-26 04:35:50 +01:00
2026-02-19 18:30:14 +01:00
const lifecycleLog = getLifecycleLog ();
lifecycleLog . log ({ event : 'server_started' , sessionId : '*' });
await lifecycleLog . trimIfNeeded ();
2026-02-12 13:44:07 +01:00
// Restore mux sessions BEFORE accepting connections
2026-01-26 04:35:50 +01:00
// This prevents race conditions where clients connect before state is ready
2026-01-30 17:50:02 +01:00
// CRITICAL: Skip in test mode to prevent tests from picking up user sessions
if ( ! this . testMode ) {
2026-02-18 12:51:35 +01:00
await this . restoreMuxSessions ();
2026-01-30 17:50:02 +01:00
}
2026-01-26 04:35:50 +01:00
2026-02-18 12:51:35 +01:00
// Clean up stale sessions from state file that don't have active mux sessions
2026-01-27 11:02:02 +01:00
this . cleanupStaleSessions ();
2026-01-18 05:14:34 +01:00
await this . app . listen ({ port : this.port , host : '0.0.0.0' });
2026-01-24 02:12:22 +01:00
const protocol = this . https ? 'https' : 'http' ;
console . log ( `Claudeman web interface running at ${ protocol } ://localhost: ${ this . port } ` );
2026-01-19 02:51:54 +01:00
2026-01-23 23:16:21 +01:00
// Set API URL for child processes (MCP server, spawned sessions)
2026-01-24 02:12:22 +01:00
process . env . CLAUDEMAN_API_URL = ` ${ protocol } ://localhost: ${ this . port } ` ;
2026-01-23 23:16:21 +01:00
2026-01-19 06:51:25 +01:00
// Start scheduled runs cleanup timer
this . scheduledCleanupTimer = setInterval (() => {
this . cleanupScheduledRuns ();
}, SCHEDULED_CLEANUP_INTERVAL );
2026-01-22 00:08:32 +01:00
// Start SSE client health check timer (prevents memory leaks from dead connections)
this . sseHealthCheckTimer = setInterval (() => {
this . cleanupDeadSSEClients ();
}, SSE_HEALTH_CHECK_INTERVAL );
2026-01-25 20:36:04 +01:00
// Start token recording timer (every 5 minutes for long-running sessions)
this . tokenRecordingTimer = setInterval (() => {
this . recordPeriodicTokenUsage ();
}, 5 * 60 * 1000 );
2026-01-25 18:09:25 +01:00
// Start subagent watcher for Claude Code background agent visibility (if enabled)
2026-02-12 20:14:01 +01:00
if ( await this . isSubagentTrackingEnabled ()) {
2026-01-25 18:09:25 +01:00
subagentWatcher . start ();
console . log ( 'Subagent watcher started - monitoring ~/.claude/projects for background agent activity' );
} else {
console . log ( 'Subagent watcher disabled by user settings' );
}
2026-01-28 02:31:58 +01:00
2026-01-28 07:07:18 +01:00
// Start image watcher for auto-popup of screenshots (if enabled)
2026-02-12 20:14:01 +01:00
if ( await this . isImageWatcherEnabled ()) {
2026-01-28 07:07:18 +01:00
imageWatcher . start ();
console . log ( 'Image watcher started - monitoring session directories for new images' );
} else {
console . log ( 'Image watcher disabled by user settings' );
}
2026-02-12 08:34:51 +01:00
2026-02-19 14:00:10 +01:00
// 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' );
2026-01-25 18:09:25 +01:00
}
/**
* Check if subagent tracking is enabled in settings (default: true)
*/
2026-02-12 20:14:01 +01:00
private async isSubagentTrackingEnabled () : Promise < boolean > {
2026-01-25 18:09:25 +01:00
const settingsPath = join ( homedir (), '.claudeman' , 'settings.json' );
try {
2026-02-12 20:14:01 +01:00
const content = await fs . readFile ( settingsPath , 'utf-8' );
const settings = JSON . parse ( content );
// Default to true if not explicitly set
return settings . subagentTrackingEnabled ?? true ;
2026-01-25 18:09:25 +01:00
} catch ( err ) {
2026-02-12 20:14:01 +01:00
if (( err as NodeJS . ErrnoException ). code !== 'ENOENT' ) {
console . error ( 'Failed to read subagent tracking setting:' , err );
}
2026-01-25 18:09:25 +01:00
}
return true ; // Default enabled
2026-01-19 02:51:54 +01:00
}
2026-01-28 07:07:18 +01:00
/**
2026-02-17 16:47:54 +01:00
* Check if image watcher is enabled in settings (default: false)
2026-01-28 07:07:18 +01:00
*/
2026-02-12 20:14:01 +01:00
private async isImageWatcherEnabled () : Promise < boolean > {
2026-01-28 07:07:18 +01:00
const settingsPath = join ( homedir (), '.claudeman' , 'settings.json' );
try {
2026-02-12 20:14:01 +01:00
const content = await fs . readFile ( settingsPath , 'utf-8' );
const settings = JSON . parse ( content );
2026-02-17 16:47:54 +01:00
// Default to false if not explicitly set (matches UI default)
return settings . imageWatcherEnabled ?? false ;
2026-01-28 07:07:18 +01:00
} catch ( err ) {
2026-02-12 20:14:01 +01:00
if (( err as NodeJS . ErrnoException ). code !== 'ENOENT' ) {
console . error ( 'Failed to read image watcher setting:' , err );
}
2026-01-28 07:07:18 +01:00
}
2026-02-17 16:47:54 +01:00
return false ; // Default disabled (matches UI default)
2026-01-28 07:07:18 +01:00
}
2026-02-18 12:51:35 +01:00
private async restoreMuxSessions () : Promise < void > {
2026-01-19 02:51:54 +01:00
try {
2026-02-18 12:51:35 +01:00
// Reconcile mux sessions to find which ones are still alive (also discovers unknown ones)
2026-02-12 10:29:57 +01:00
const { alive , dead , discovered } = await this . mux . reconcileSessions ();
2026-01-19 02:51:54 +01:00
2026-01-19 11:24:14 +01:00
if ( discovered . length > 0 ) {
2026-02-12 13:44:07 +01:00
console . log ( `[Server] Discovered ${ discovered . length } unknown mux session(s)` );
2026-01-19 11:24:14 +01:00
}
if ( alive . length > 0 || discovered . length > 0 ) {
2026-02-12 13:44:07 +01:00
console . log ( `[Server] Found ${ alive . length + discovered . length } alive mux session(s) from previous run` );
2026-01-19 02:51:54 +01:00
2026-02-18 12:51:35 +01:00
// 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 )) {
2026-01-29 12:47:23 +01:00
// Restore session settings from state.json (single source of truth)
2026-02-18 12:51:35 +01:00
const savedState = this . store . getSession ( muxSession . sessionId );
2026-01-29 12:47:23 +01:00
2026-02-18 12:51:35 +01:00
// Determine the correct session name (priority: savedState > muxSession > muxName)
2026-01-29 12:47:23 +01:00
// This ensures renamed sessions keep their name after server restart
2026-02-18 12:51:35 +01:00
const sessionName = savedState ? . name || muxSession . name || muxSession . muxName ;
2026-01-29 12:47:23 +01:00
2026-02-18 12:51:35 +01:00
// Create a session object for this mux session
2026-01-19 02:51:54 +01:00
const session = new Session ({
2026-02-18 12:51:35 +01:00
id : muxSession.sessionId , // Preserve the original session ID
workingDir : muxSession.workingDir ,
mode : muxSession.mode ,
2026-01-29 12:47:23 +01:00
name : sessionName ,
2026-02-12 10:29:57 +01:00
mux : this.mux ,
useMux : true ,
2026-02-18 12:51:35 +01:00
muxSession : muxSession // Pass the existing session so startInteractive() can attach to it
2026-01-19 02:51:54 +01:00
});
2026-02-18 12:51:35 +01:00
// 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 );
2026-01-29 12:47:23 +01:00
}
2026-01-22 20:59:18 +01:00
if ( savedState ) {
2026-01-22 21:17:55 +01:00
// Auto-compact
2026-01-22 20:59:18 +01:00
if ( savedState . autoCompactEnabled !== undefined || savedState . autoCompactThreshold !== undefined ) {
session . setAutoCompact (
savedState . autoCompactEnabled ?? false ,
savedState . autoCompactThreshold ,
savedState . autoCompactPrompt
);
}
2026-01-22 21:17:55 +01:00
// Auto-clear
2026-01-22 20:59:18 +01:00
if ( savedState . autoClearEnabled !== undefined || savedState . autoClearThreshold !== undefined ) {
session . setAutoClear (
savedState . autoClearEnabled ?? false ,
savedState . autoClearThreshold
);
}
2026-01-25 07:26:49 +01:00
// Token tracking
if ( savedState . inputTokens !== undefined || savedState . outputTokens !== undefined || savedState . totalCost !== undefined ) {
session . restoreTokens (
savedState . inputTokens ?? 0 ,
savedState . outputTokens ?? 0 ,
savedState . totalCost ?? 0
);
2026-01-26 03:13:54 +01:00
// 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 ,
});
2026-01-25 07:26:49 +01:00
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 ) } ` );
}
}
2026-01-22 21:17:55 +01:00
// Ralph / Todo tracker
2026-01-30 17:20:53 +01:00
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 ();
}
2026-01-22 21:17:55 +01:00
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' } )` );
}
2026-01-27 11:43:20 +01:00
// Nice priority config
if ( savedState . niceEnabled !== undefined ) {
session . setNice ({
enabled : savedState.niceEnabled ,
niceValue : savedState.niceValue ,
2026-01-27 01:58:02 +01:00
});
}
2026-01-30 17:50:02 +01:00
// Flicker filter (frontend-applied but persisted)
if ( savedState . flickerFilterEnabled !== undefined ) {
session . flickerFilterEnabled = savedState . flickerFilterEnabled ;
}
2026-01-22 21:17:55 +01:00
// Respawn controller
if ( savedState . respawnEnabled && savedState . respawnConfig ) {
try {
2026-01-25 08:25:51 +01:00
this . restoreRespawnController ( session , savedState . respawnConfig , 'state.json' );
2026-01-22 21:17:55 +01:00
} catch ( err ) {
console . error ( `[Server] Failed to restore respawn for session ${ session . id } :` , err );
}
}
2026-01-22 20:59:18 +01:00
}
2026-02-18 12:51:35 +01:00
// Fallback: restore respawn from mux-sessions.json if state.json didn't have it
if ( ! this . respawnControllers . has ( session . id ) && muxSession . respawnConfig ? . enabled ) {
2026-01-22 21:23:59 +01:00
try {
2026-02-18 12:51:35 +01:00
this . restoreRespawnController ( session , muxSession . respawnConfig , 'mux-sessions.json' );
2026-01-22 21:23:59 +01:00
} catch ( err ) {
2026-02-18 12:51:35 +01:00
console . error ( `[Server] Failed to restore respawn from mux-sessions.json for session ${ session . id } :` , err );
2026-01-22 21:23:59 +01:00
}
}
2026-01-30 17:20:53 +01:00
// Fallback: restore Ralph state from state-inner.json if not already set and not explicitly disabled
if ( ! session . ralphTracker . enabled && ! session . ralphTracker . autoEnableDisabled ) {
2026-02-18 12:51:35 +01:00
const ralphState = this . store . getRalphState ( muxSession . sessionId );
2026-01-22 21:17:55 +01:00
if ( ralphState ? . loop ? . enabled ) {
session . ralphTracker . restoreState ( ralphState . loop , ralphState . todos );
console . log ( `[Server] Restored Ralph state from inner store for session ${ session . id } ` );
}
2026-01-21 18:30:06 +01:00
}
2026-01-22 21:17:55 +01:00
// Fallback: auto-detect completion phrase from CLAUDE.md
if ( session . ralphTracker . enabled && ! session . ralphTracker . loopState . completionPhrase ) {
2026-01-21 19:49:44 +01:00
const claudeMdPath = join ( session . workingDir , 'CLAUDE.md' );
const completionPhrase = extractCompletionPhrase ( claudeMdPath );
if ( completionPhrase ) {
2026-01-21 22:36:45 +01:00
session . ralphTracker . startLoop ( completionPhrase );
2026-01-21 19:49:44 +01:00
console . log ( `[Server] Auto-detected completion phrase for session ${ session . id } : ${ completionPhrase } ` );
}
}
2026-01-22 21:17:55 +01:00
this . sessions . set ( session . id , session );
2026-02-12 20:14:01 +01:00
await this . setupSessionListeners ( session );
2026-01-22 21:13:23 +01:00
this . persistSessionState ( session );
2026-01-19 02:51:54 +01:00
// Mark it as restored (not started yet - user needs to attach)
2026-02-19 18:30:14 +01:00
getLifecycleLog (). log ({ event : 'recovered' , sessionId : session.id , name : session.name });
2026-02-18 12:51:35 +01:00
console . log ( `[Server] Restored session ${ session . id } from mux ${ muxSession . muxName } ` );
2026-01-19 02:51:54 +01:00
}
}
2026-02-18 12:51:35 +01:00
// Start stats collection for mux sessions
2026-02-12 10:29:57 +01:00
this . mux . startStatsCollection ( STATS_COLLECTION_INTERVAL_MS );
2026-02-19 08:49:36 +01:00
}
2026-02-18 10:23:10 +01:00
2026-02-19 08:49:36 +01:00
// 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 ();
2026-01-19 02:51:54 +01:00
}
if ( dead . length > 0 ) {
2026-02-12 13:44:07 +01:00
console . log ( `[Server] Cleaned up ${ dead . length } dead mux session(s)` );
2026-01-19 02:51:54 +01:00
}
} catch ( err ) {
2026-02-12 13:44:07 +01:00
console . error ( '[Server] Failed to restore mux sessions:' , err );
2026-01-19 02:51:54 +01:00
}
2026-01-18 05:14:34 +01:00
}
async stop () : Promise < void > {
2026-02-19 18:30:14 +01:00
getLifecycleLog (). log ({ event : 'server_stopped' , sessionId : '*' });
2026-01-22 00:08:32 +01:00
// 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 ;
}
2026-01-27 20:24:00 +01:00
// 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
}
}
2026-01-22 00:08:32 +01:00
this . sseClients . clear ();
2026-02-12 18:26:16 +01:00
this . backpressuredClients . clear ();
2026-01-22 00:08:32 +01:00
2026-01-19 06:51:25 +01:00
// Clear batch timers
2026-01-18 21:36:03 +01:00
if ( this . terminalBatchTimer ) {
clearTimeout ( this . terminalBatchTimer );
this . terminalBatchTimer = null ;
}
this . terminalBatches . clear ();
2026-02-19 18:30:14 +01:00
this . terminalBatchSizes . clear ();
2026-01-19 06:51:25 +01:00
if ( this . taskUpdateBatchTimer ) {
clearTimeout ( this . taskUpdateBatchTimer );
this . taskUpdateBatchTimer = null ;
}
this . taskUpdateBatches . clear ();
2026-01-21 05:53:59 +01:00
if ( this . stateUpdateTimer ) {
clearTimeout ( this . stateUpdateTimer );
this . stateUpdateTimer = null ;
}
this . stateUpdatePending . clear ();
2026-01-25 20:36:04 +01:00
// Clear token recording timer
if ( this . tokenRecordingTimer ) {
clearInterval ( this . tokenRecordingTimer );
this . tokenRecordingTimer = null ;
}
this . lastRecordedTokens . clear ();
2026-01-19 06:51:25 +01:00
// Clear scheduled cleanup timer
if ( this . scheduledCleanupTimer ) {
clearInterval ( this . scheduledCleanupTimer );
this . scheduledCleanupTimer = null ;
}
2026-02-12 10:29:57 +01:00
// Stop multiplexer and flush pending saves
this . mux . destroy ();
2026-01-19 02:18:51 +01:00
2026-02-15 05:33:34 +01:00
// 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 ;
2026-01-26 03:08:13 +01:00
// Clear all pending respawn start timers (from restoration grace period)
for ( const timer of this . pendingRespawnStarts . values ()) {
clearTimeout ( timer );
}
this . pendingRespawnStarts . clear ();
2026-01-21 09:54:42 +01:00
// Stop all respawn controllers and remove listeners
2026-01-18 07:31:19 +01:00
for ( const controller of this . respawnControllers . values ()) {
controller . stop ();
2026-01-21 09:54:42 +01:00
controller . removeAllListeners ();
2026-01-18 07:31:19 +01:00
}
this . respawnControllers . clear ();
2026-01-21 09:54:42 +01:00
// Stop all scheduled runs first (they have their own session cleanup)
2026-02-12 20:14:01 +01:00
await Promise . allSettled (
Array . from ( this . scheduledRuns . keys ()). map ( id => this . stopScheduledRun ( id ))
);
2026-01-18 05:14:34 +01:00
2026-02-19 22:15:36 +01:00
// 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 ( '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
2026-01-21 09:54:42 +01:00
2026-01-21 10:05:39 +01:00
// Flush state store to prevent data loss from debounced saves
this . store . flushAll ();
2026-01-30 13:25:18 +01:00
// Clean up watcher listeners to prevent memory leaks
this . cleanupSubagentWatcherListeners ();
this . cleanupImageWatcherListeners ();
2026-02-19 14:00:10 +01:00
this . cleanupTeamWatcherListeners ();
2026-01-30 13:25:18 +01:00
2026-01-25 14:33:12 +01:00
// Stop subagent watcher
subagentWatcher . stop ();
2026-01-28 02:31:58 +01:00
// Stop image watcher
imageWatcher . stop ();
2026-02-19 14:00:10 +01:00
// Stop team watcher
this . teamWatcher . stop ();
2026-02-04 07:24:32 +01:00
// Destroy file stream manager (clears cleanup timer and kills remaining tail processes)
fileStreamManager . destroy ();
2026-02-12 14:41:27 +01:00
// 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 ();
}
2026-02-04 07:24:32 +01:00
// Clear remaining Maps that accumulate session references
this . respawnTimers . clear ();
this . runSummaryTrackers . clear ();
this . transcriptWatchers . clear ();
this . sessionListenerRefs . clear ();
this . scheduledRuns . clear ();
// Dispose StaleExpirationMap (stops internal cleanup timer)
this . lastTerminalEventTime . dispose ();
this . adaptiveBatchIntervals . clear ();
2026-02-12 20:14:01 +01:00
this . _minBatchInterval = TERMINAL_BATCH_INTERVAL ;
2026-02-04 07:24:32 +01:00
this . activePlanOrchestrators . clear ();
this . cleaningUp . clear ();
2026-01-18 05:14:34 +01:00
await this . app . close ();
}
}
2026-01-30 17:50:02 +01:00
export async function startWebServer ( port : number = 3000 , https : boolean = false , testMode : boolean = false ) : Promise < WebServer > {
const server = new WebServer ( port , https , testMode );
2026-01-18 05:14:34 +01:00
await server . start ();
return server ;
}