From 31d3e7bc702c03e0a2493c6d8fc46f9e44daa7b4 Mon Sep 17 00:00:00 2001 From: arkon Date: Wed, 18 Feb 2026 07:06:34 +0100 Subject: [PATCH] chore: bump version to 0.1528 Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- package.json | 5 +- src/cli.ts | 6 +- src/hooks-config.ts | 37 ++-- src/ralph-tracker.ts | 8 +- src/respawn-controller.ts | 46 +++-- src/state-store.ts | 5 +- src/subagent-watcher.ts | 25 ++- src/team-watcher.ts | 63 ++++--- src/transcript-watcher.ts | 77 +++++--- src/utils/claude-cli-resolver.ts | 6 +- src/web/public/app.js | 214 ++++++++++++++-------- src/web/schemas.ts | 53 ++++-- src/web/server.ts | 296 +++++++++++++++++++------------ 14 files changed, 540 insertions(+), 303 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f85491ae..6b803ac6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1527 (must match `package.json` for npm publish) +**Version**: 0.1528 (must match `package.json` for npm publish) ## Project Overview diff --git a/package.json b/package.json index 8740bd0a..3863d73e 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,10 @@ { "name": "claudeman", - "version": "0.1527", + "version": "0.1528", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", + "types": "dist/index.d.ts", "bin": { "claudeman": "./dist/index.js" }, @@ -33,7 +34,7 @@ "autonomous-agent", "ralph-loop" ], - "author": "", + "author": "arkon", "license": "MIT", "dependencies": { "@fastify/compress": "^8.3.1", diff --git a/src/cli.ts b/src/cli.ts index 777b1042..ad4fca2e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,18 +9,22 @@ import { Command } from 'commander'; import chalk from 'chalk'; +import { createRequire } from 'module'; import { getSessionManager } from './session-manager.js'; import { getTaskQueue } from './task-queue.js'; import { getRalphLoop } from './ralph-loop.js'; import { getStore } from './state-store.js'; import { getErrorMessage } from './types.js'; +const require = createRequire(import.meta.url); +const pkg = require('../package.json') as { version: string }; + const program = new Command(); program .name('claudeman') .description('Claude Code session manager with autonomous Ralph Loop') - .version('1.0.0'); + .version(pkg.version); // ============ Session Commands ============ diff --git a/src/hooks-config.ts b/src/hooks-config.ts index c709f5cf..d00627ea 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -8,7 +8,8 @@ * config is static per case directory. */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync } from 'node:fs'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import type { HookEventType } from './types.js'; @@ -70,21 +71,19 @@ export function generateHooksConfig(): { hooks: Record } { * Updates env vars in .claude/settings.local.json for the given case path. * Merges with existing env field; removes vars set to empty string. */ -export function updateCaseEnvVars(casePath: string, envVars: Record): void { +export async function updateCaseEnvVars(casePath: string, envVars: Record): Promise { const claudeDir = join(casePath, '.claude'); if (!existsSync(claudeDir)) { - mkdirSync(claudeDir, { recursive: true }); + await mkdir(claudeDir, { recursive: true }); } const settingsPath = join(claudeDir, 'settings.local.json'); let existing: Record = {}; - if (existsSync(settingsPath)) { - try { - existing = JSON.parse(readFileSync(settingsPath, 'utf-8')); - } catch { - existing = {}; - } + try { + existing = JSON.parse(await readFile(settingsPath, 'utf-8')); + } catch { + existing = {}; } const currentEnv = (existing.env as Record) || {}; @@ -97,33 +96,31 @@ export function updateCaseEnvVars(casePath: string, envVars: Record { const claudeDir = join(casePath, '.claude'); if (!existsSync(claudeDir)) { - mkdirSync(claudeDir, { recursive: true }); + await mkdir(claudeDir, { recursive: true }); } const settingsPath = join(claudeDir, 'settings.local.json'); let existing: Record = {}; - if (existsSync(settingsPath)) { - try { - existing = JSON.parse(readFileSync(settingsPath, 'utf-8')); - } catch { - // If file is malformed, start fresh - existing = {}; - } + try { + existing = JSON.parse(await readFile(settingsPath, 'utf-8')); + } catch { + // If file is malformed or doesn't exist, start fresh + existing = {}; } const hooksConfig = generateHooksConfig(); const merged = { ...existing, ...hooksConfig }; - writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n'); + await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n'); } diff --git a/src/ralph-tracker.ts b/src/ralph-tracker.ts index 785272ae..37fa8683 100644 --- a/src/ralph-tracker.ts +++ b/src/ralph-tracker.ts @@ -141,6 +141,7 @@ const EVENT_DEBOUNCE_MS = 50; * Prevents unbounded growth if many unique phrases are seen. */ const MAX_COMPLETION_PHRASE_ENTRIES = 50; +const MAX_PLAN_HISTORY = 10; /** * Common/generic completion phrases that may cause false positives. @@ -3711,7 +3712,7 @@ export class RalphTracker extends EventEmitter { }); // Limit history size - if (this._planHistory.length > 10) { + if (this._planHistory.length > MAX_PLAN_HISTORY) { this._planHistory.shift(); } } @@ -3846,6 +3847,11 @@ export class RalphTracker extends EventEmitter { this._alternateCompletionPhrases.clear(); this._completionPhraseCount.clear(); this._planTasks.clear(); + this._completionTimes.length = 0; + this._lineBuffer = ''; + this._partialPromiseBuffer = ''; + this._statusBlockBuffer.length = 0; + this._planHistory.length = 0; this.removeAllListeners(); } } diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index 24d3f727..cdd03c51 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -1160,6 +1160,7 @@ export class RespawnController extends EventEmitter { */ private startDetectionUpdates(): void { this.stopDetectionUpdates(); + if (this._state === 'stopped') return; this.detectionUpdateTimer = setInterval(() => { try { if (this._state !== 'stopped') { @@ -1329,7 +1330,12 @@ export class RespawnController extends EventEmitter { */ resume(): void { this.log('Resuming respawn'); - // Re-setup terminal listener if it was removed (robustness for stop->resume case) + // After a full stop(), use start() for clean restart to avoid double-registering listeners + if (this._state === 'stopped') { + this.start(); + return; + } + // Re-setup terminal listener if it was removed if (!this.terminalHandler) { this.setupTerminalListener(); } @@ -1688,6 +1694,7 @@ export class RespawnController extends EventEmitter { this.config.interStepDelayMs, async () => { this.stepTimer = null; + if (this._state === 'stopped') return; const prompt = this.config.kickstartPrompt!; this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`); await this.session.writeViaScreen(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI @@ -1728,6 +1735,8 @@ export class RespawnController extends EventEmitter { /** Clear all timers (idle, step, completion confirm, no-output, pre-filter, step confirm, auto-accept, hook confirm, and clear fallback) */ private clearTimers(): void { + // Clear tracked timers map first to avoid stale entries during individual cleanup + this.activeTimers.clear(); this.clearIdleTimer(); if (this.stepTimer) { clearTimeout(this.stepTimer); @@ -1769,8 +1778,6 @@ export class RespawnController extends EventEmitter { clearInterval(this.detectionUpdateTimer); this.detectionUpdateTimer = null; } - // Clear all tracked timers - this.activeTimers.clear(); } // ========== Stuck-State Detection Methods ========== @@ -1901,6 +1908,9 @@ export class RespawnController extends EventEmitter { this.setState('watching'); this.startNoOutputTimer(); this.startPreFilterTimer(); + if (this.config.autoAcceptPrompts) { + this.startAutoAcceptTimer(); + } } } @@ -2237,6 +2247,7 @@ export class RespawnController extends EventEmitter { if (this._currentAiCheckId !== checkId) { return; // Stale check, ignore error } + if (this._state === 'stopped') return; // Guard against stopped state if (this._state === 'ai_checking') { const errorMsg = err instanceof Error ? err.message : String(err); this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`); @@ -2572,10 +2583,14 @@ export class RespawnController extends EventEmitter { // Output arrived after hook - Claude started new work this.log(`Output received after ${hookType} hook, cancelling idle confirmation`); this.logAction('hook', `${hookType} cancelled - new output detected`); - this.resetHookState(); + // Set state before resetting flags so event handlers see consistent state this.setState('watching'); + this.resetHookState(); this.startNoOutputTimer(); this.startPreFilterTimer(); + if (this.config.autoAcceptPrompts) { + this.startAutoAcceptTimer(); + } return; } @@ -2645,6 +2660,7 @@ export class RespawnController extends EventEmitter { this.config.completionConfirmMs, () => { this.completionConfirmTimer = null; + if (this._state === 'stopped') return; const msSinceOutput = Date.now() - this.lastOutputTime; if (msSinceOutput >= this.config.completionConfirmMs) { this.logAction('detection', `Silence confirmed: ${Math.round(msSinceOutput / 1000)}s`); @@ -2652,6 +2668,7 @@ export class RespawnController extends EventEmitter { } else { // Output received during wait, stay in confirming state and re-check this.logAction('detection', 'Output during confirmation, resetting'); + if (this._state !== 'confirming_idle') return; this.startCompletionConfirmTimer(); } }, @@ -2663,10 +2680,8 @@ export class RespawnController extends EventEmitter { * Cancel completion confirmation if new activity detected. */ private cancelCompletionConfirm(): void { - if (this.completionConfirmTimer) { - clearTimeout(this.completionConfirmTimer); - this.completionConfirmTimer = null; - } + this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'activity detected'); + this.completionConfirmTimer = null; if (this._state === 'confirming_idle') { this.setState('watching'); this.completionMessageTime = null; @@ -2687,6 +2702,7 @@ export class RespawnController extends EventEmitter { this.config.completionConfirmMs, () => { this.stepConfirmTimer = null; + if (this._state === 'stopped') return; const msSinceOutput = Date.now() - this.lastOutputTime; if (msSinceOutput >= this.config.completionConfirmMs) { @@ -2718,11 +2734,8 @@ export class RespawnController extends EventEmitter { * Cancel step confirmation if working patterns detected. */ private cancelStepConfirm(): void { - if (this.stepConfirmTimer) { - clearTimeout(this.stepConfirmTimer); - this.stepConfirmTimer = null; - this.log(`Step confirmation cancelled (working detected)`); - } + this.cancelTrackedTimer('step-confirm', this.stepConfirmTimer, 'working detected'); + this.stepConfirmTimer = null; } /** @@ -2803,6 +2816,9 @@ export class RespawnController extends EventEmitter { return; } + // Clear all detection timers before starting cycle to prevent stale callbacks + this.clearTimers(); + // P1-006: Session health check before respawn cycle // Skip if session is in error state or not running if (this.session.status === 'error') { @@ -2856,6 +2872,7 @@ export class RespawnController extends EventEmitter { this.config.interStepDelayMs, async () => { this.stepTimer = null; + if (this._state === 'stopped') return; // Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config const statusBlock = this.session.ralphTracker?.lastStatusBlock; @@ -2895,6 +2912,7 @@ export class RespawnController extends EventEmitter { this.config.interStepDelayMs, async () => { this.stepTimer = null; + if (this._state === 'stopped') return; this.logAction('command', 'Sending: /clear'); await this.session.writeViaScreen('/clear\r'); // \r triggers Enter in Ink/Claude CLI this.emit('stepSent', 'clear', '/clear'); @@ -2938,6 +2956,7 @@ export class RespawnController extends EventEmitter { this.config.interStepDelayMs, async () => { this.stepTimer = null; + if (this._state === 'stopped') return; this.logAction('command', 'Sending: /init'); await this.session.writeViaScreen('/init\r'); // \r triggers Enter in Ink/Claude CLI this.emit('stepSent', 'init', '/init'); @@ -2985,6 +3004,7 @@ export class RespawnController extends EventEmitter { * Used when resuming from pause. */ private checkIdleAndMaybeStart(): void { + if (this._state === 'stopped') return; // Check if already idle const timeSinceActivity = Date.now() - this.lastActivityTime; if (timeSinceActivity > this.config.idleTimeoutMs && this.promptDetected) { diff --git a/src/state-store.ts b/src/state-store.ts index e87073d2..61e9cdb8 100644 --- a/src/state-store.ts +++ b/src/state-store.ts @@ -243,7 +243,6 @@ export class StateStore { return; } - this.dirty = false; this.ensureDir(); const tempPath = this.filePath + '.tmp'; @@ -259,7 +258,6 @@ export class StateStore { console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly'); this.circuitBreakerOpen = true; } - this.dirty = true; return; } @@ -275,6 +273,8 @@ export class StateStore { try { writeFileSync(tempPath, json, 'utf-8'); renameSync(tempPath, this.filePath); + // Clear dirty flag only AFTER successful write + this.dirty = false; this.consecutiveSaveFailures = 0; if (this.circuitBreakerOpen) { console.log('[StateStore] Circuit breaker CLOSED - save succeeded'); @@ -288,7 +288,6 @@ export class StateStore { console.error('[StateStore] Circuit breaker OPEN - writes failing repeatedly'); this.circuitBreakerOpen = true; } - this.dirty = true; } } diff --git a/src/subagent-watcher.ts b/src/subagent-watcher.ts index f38eb22e..a31ae3c2 100644 --- a/src/subagent-watcher.ts +++ b/src/subagent-watcher.ts @@ -949,6 +949,7 @@ export class SubagentWatcher extends EventEmitter { // Store handler reference for proper cleanup const errorHandler = (error: Error) => { this.emit('subagent:error', error instanceof Error ? error : new Error(String(error))); + watcher.close(); this.dirWatcherErrorHandlers.delete(dir); this.dirWatchers.delete(dir); this.knownSubagentDirs.delete(dir); @@ -1019,6 +1020,21 @@ export class SubagentWatcher extends EventEmitter { description, }; + // Enforce MAX_TRACKED_AGENTS during insertion — evict oldest inactive agent + if (this.agentInfo.size >= MAX_TRACKED_AGENTS) { + let oldestId: string | null = null; + let oldestTime = Infinity; + for (const [id, existing] of this.agentInfo) { + if (existing.status !== 'active' && existing.lastActivityAt < oldestTime) { + oldestTime = existing.lastActivityAt; + oldestId = id; + } + } + if (oldestId) { + this.removeAgent(oldestId); + } + } + this.agentInfo.set(agentId, info); this.emit('subagent:discovered', info); @@ -1083,6 +1099,7 @@ export class SubagentWatcher extends EventEmitter { // Store handler reference for proper cleanup const errorHandler = (error: Error) => { this.emit('subagent:error', error instanceof Error ? error : new Error(String(error)), agentId); + watcher.close(); this.fileWatcherErrorHandlers.delete(filePath); this.fileWatchers.delete(filePath); }; @@ -1113,11 +1130,13 @@ export class SubagentWatcher extends EventEmitter { rl.on('line', (line) => { const lineBytes = Buffer.byteLength(line, 'utf8') + 1; + position += lineBytes; // Always advance past the line try { const entry = JSON.parse(line) as SubagentTranscriptEntry; - this.processEntry(entry, agentId, sessionId); - position += lineBytes; // Only advance on successful parse + this.processEntry(entry, agentId, sessionId).catch(() => { + // processEntry failure is non-critical + }); // Update entry count const info = this.agentInfo.get(agentId); @@ -1125,7 +1144,7 @@ export class SubagentWatcher extends EventEmitter { info.entryCount++; } } catch { - // Don't advance position - will retry on next change event + // Malformed JSON line — skip it } }); diff --git a/src/team-watcher.ts b/src/team-watcher.ts index 0c48d831..06e84a02 100644 --- a/src/team-watcher.ts +++ b/src/team-watcher.ts @@ -35,6 +35,8 @@ export class TeamWatcher extends EventEmitter { private configMtimes: Map = new Map(); private taskMtimes: Map = new Map(); private inboxMtimes: Map = new Map(); + // Reverse index: sessionId → teamName for O(1) lookup + private sessionToTeam: Map = new Map(); constructor(teamsDir?: string, tasksDir?: string) { super(); @@ -60,6 +62,7 @@ export class TeamWatcher extends EventEmitter { this.configMtimes.clear(); this.taskMtimes.clear(); this.inboxMtimes.clear(); + this.sessionToTeam.clear(); } /** Get all discovered teams */ @@ -69,10 +72,12 @@ export class TeamWatcher extends EventEmitter { /** Get team associated with a Claudeman session (matched by leadSessionId) */ getTeamForSession(sessionId: string): TeamConfig | undefined { - for (const team of this.teams.values()) { - if (team.leadSessionId === sessionId) { - return team; - } + const teamName = this.sessionToTeam.get(sessionId); + if (teamName) { + const team = this.teams.get(teamName); + if (team) return team; + // Stale reverse index entry — clean up + this.sessionToTeam.delete(sessionId); } return undefined; } @@ -191,6 +196,11 @@ export class TeamWatcher extends EventEmitter { const existing = this.teams.get(entry); this.teams.set(entry, config); + // Maintain reverse index + if (existing && existing.leadSessionId !== config.leadSessionId) { + this.sessionToTeam.delete(existing.leadSessionId); + } + this.sessionToTeam.set(config.leadSessionId, entry); if (existing) { this.emit('teamUpdated', config); @@ -205,6 +215,10 @@ export class TeamWatcher extends EventEmitter { const removed = this.teams.get(name); this.teams.delete(name); this.configMtimes.delete(name); + // Clean up reverse index + if (removed) { + this.sessionToTeam.delete(removed.leadSessionId); + } // Prune stale mtime entries for removed teams this.taskMtimes.delete(name); for (const key of Array.from(this.inboxMtimes.keys())) { @@ -229,6 +243,18 @@ export class TeamWatcher extends EventEmitter { for (const teamName of teamDirs) { const teamTaskDir = join(this.tasksDir, teamName); + + // Use directory mtime as a cheap change indicator (single stat instead of N) + const mtimeKey = teamName; + try { + const dirStat = await stat(teamTaskDir); + const dirMtime = `${dirStat.mtimeMs}`; + if (this.taskMtimes.get(mtimeKey) === dirMtime) continue; + this.taskMtimes.set(mtimeKey, dirMtime); + } catch { + continue; + } + let taskFiles: string[]; try { taskFiles = (await readdir(teamTaskDir)).filter(f => f.endsWith('.json') && f !== '.lock'); @@ -236,25 +262,6 @@ export class TeamWatcher extends EventEmitter { continue; } - // Check combined mtime for all task files (use count:max:sum to avoid collisions) - const mtimeKey = teamName; - let mtimeSum = 0; - let mtimeMax = 0; - let mtimeCount = 0; - for (const f of taskFiles) { - try { - const mt = (await stat(join(teamTaskDir, f))).mtimeMs; - mtimeSum += mt; - if (mt > mtimeMax) mtimeMax = mt; - mtimeCount++; - } catch { - // File may have been deleted between readdir and stat - } - } - const combinedMtime = `${mtimeCount}:${mtimeMax}:${mtimeSum}`; - if (this.taskMtimes.get(mtimeKey) === combinedMtime) continue; - this.taskMtimes.set(mtimeKey, combinedMtime); - // Skip if locked if (await this.isLocked(join(teamTaskDir, '.lock'))) continue; @@ -306,11 +313,11 @@ export class TeamWatcher extends EventEmitter { const previous = this.inboxCache.get(cacheKey); this.inboxCache.set(cacheKey, messages); - // Emit new messages (ones not in previous cache) - const prevCount = previous?.length || 0; - if (messages.length > prevCount) { - for (let i = prevCount; i < messages.length; i++) { - this.emit('inboxMessage', { teamName, member: memberName, message: messages[i] }); + // Emit new messages — compare by timestamp to handle deletions/reordering + const prevTimestamps = new Set(previous?.map(m => m.timestamp) || []); + for (const msg of messages) { + if (!prevTimestamps.has(msg.timestamp)) { + this.emit('inboxMessage', { teamName, member: memberName, message: msg }); } } } diff --git a/src/transcript-watcher.ts b/src/transcript-watcher.ts index eafc2ec7..28d6a461 100644 --- a/src/transcript-watcher.ts +++ b/src/transcript-watcher.ts @@ -101,6 +101,7 @@ export class TranscriptWatcher extends EventEmitter { private pollInterval: NodeJS.Timeout | null = null; private filePosition: number = 0; private _isRunning: boolean = false; + private _isProcessing: boolean = false; private state: TranscriptState = this.getInitialState(); constructor() { @@ -252,6 +253,8 @@ export class TranscriptWatcher extends EventEmitter { private async processNewContent(): Promise { if (!this.transcriptPath || !this._isRunning) return; + if (this._isProcessing) return; // Guard against concurrent calls + this._isProcessing = true; try { const stat = statSync(this.transcriptPath); @@ -275,6 +278,8 @@ export class TranscriptWatcher extends EventEmitter { } } catch (err) { this.emit('transcript:error', err as Error); + } finally { + this._isProcessing = false; } } @@ -286,38 +291,54 @@ export class TranscriptWatcher extends EventEmitter { } const entries: TranscriptEntry[] = []; - const stream = createReadStream(this.transcriptPath, { + // Read raw buffer to detect actual line endings (LF vs CRLF) + const transcriptPath = this.transcriptPath; + let rawChunks: Buffer[] = []; + const rawStream = createReadStream(transcriptPath, { start: this.filePosition, - encoding: 'utf-8', }); + rawStream.on('data', (chunk: Buffer | string) => rawChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + rawStream.on('end', () => { + // Detect if file uses CRLF + const raw = Buffer.concat(rawChunks); + rawChunks = []; // Free memory + const hasCRLF = raw.includes(0x0d); // 0x0d = \r + const lineEndingSize = hasCRLF ? 2 : 1; - const rl = createInterface({ - input: stream, - crlfDelay: Infinity, + const stream = createReadStream(transcriptPath, { + start: this.filePosition, + encoding: 'utf-8', + }); + + const rl = createInterface({ + input: stream, + crlfDelay: Infinity, + }); + + let bytesRead = this.filePosition; + + rl.on('line', (line) => { + bytesRead += Buffer.byteLength(line, 'utf-8') + lineEndingSize; + + if (!line.trim()) return; + + try { + const entry = JSON.parse(line) as TranscriptEntry; + entries.push(entry); + } catch { + // Skip malformed lines + } + }); + + rl.on('close', () => { + this.filePosition = bytesRead; + resolve(entries); + }); + + rl.on('error', reject); + stream.on('error', reject); }); - - let bytesRead = this.filePosition; - - rl.on('line', (line) => { - bytesRead += Buffer.byteLength(line, 'utf-8') + 1; // +1 for newline - - if (!line.trim()) return; - - try { - const entry = JSON.parse(line) as TranscriptEntry; - entries.push(entry); - } catch { - // Skip malformed lines - } - }); - - rl.on('close', () => { - this.filePosition = bytesRead; - resolve(entries); - }); - - rl.on('error', reject); - stream.on('error', reject); + rawStream.on('error', reject); }); } diff --git a/src/utils/claude-cli-resolver.ts b/src/utils/claude-cli-resolver.ts index d68514c4..c978c55e 100644 --- a/src/utils/claude-cli-resolver.ts +++ b/src/utils/claude-cli-resolver.ts @@ -10,7 +10,7 @@ import { execSync } from 'node:child_process'; import { existsSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { delimiter, dirname, join } from 'node:path'; import { homedir } from 'node:os'; /** Timeout for exec commands (5 seconds) */ @@ -77,8 +77,8 @@ export function getAugmentedPath(): string { const currentPath = process.env.PATH || ''; const claudeDir = findClaudeDir(); - if (claudeDir && !currentPath.split(':').includes(claudeDir)) { - _augmentedPath = `${claudeDir}:${currentPath}`; + if (claudeDir && !currentPath.split(delimiter).includes(claudeDir)) { + _augmentedPath = `${claudeDir}${delimiter}${currentPath}`; return _augmentedPath; } diff --git a/src/web/public/app.js b/src/web/public/app.js index bd3a3e1f..06c0e39d 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -190,10 +190,19 @@ const MobileDetection = { this.updateBodyClass(); // Debounced resize handler let resizeTimeout; - window.addEventListener('resize', () => { + this._resizeHandler = () => { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => this.updateBodyClass(), 100); - }); + }; + window.addEventListener('resize', this._resizeHandler); + }, + + /** Remove event listeners */ + cleanup() { + if (this._resizeHandler) { + window.removeEventListener('resize', this._resizeHandler); + this._resizeHandler = null; + } } }; @@ -235,7 +244,7 @@ const KeyboardHandler = { this.lastViewportHeight = this.initialViewportHeight; // Simple focus handler - scroll input into view after keyboard appears - document.addEventListener('focusin', (e) => { + this._focusinHandler = (e) => { const target = e.target; if (!this.isInputElement(target)) return; @@ -243,17 +252,36 @@ const KeyboardHandler = { setTimeout(() => { this.scrollInputIntoView(target); }, 400); - }); + }; + document.addEventListener('focusin', this._focusinHandler); // Use visualViewport to detect keyboard and reposition toolbar if (window.visualViewport) { - window.visualViewport.addEventListener('resize', () => { + this._viewportResizeHandler = () => { this.handleViewportResize(); - }); - // Also handle scroll (iOS scrolls viewport when keyboard appears) - window.visualViewport.addEventListener('scroll', () => { + }; + this._viewportScrollHandler = () => { this.updateLayoutForKeyboard(); - }); + }; + window.visualViewport.addEventListener('resize', this._viewportResizeHandler); + // Also handle scroll (iOS scrolls viewport when keyboard appears) + window.visualViewport.addEventListener('scroll', this._viewportScrollHandler); + } + }, + + /** Remove event listeners */ + cleanup() { + if (this._focusinHandler) { + document.removeEventListener('focusin', this._focusinHandler); + this._focusinHandler = null; + } + if (this._viewportResizeHandler && window.visualViewport) { + window.visualViewport.removeEventListener('resize', this._viewportResizeHandler); + this._viewportResizeHandler = null; + } + if (this._viewportScrollHandler && window.visualViewport) { + window.visualViewport.removeEventListener('scroll', this._viewportScrollHandler); + this._viewportScrollHandler = null; } }, @@ -375,6 +403,9 @@ const KeyboardHandler = { this._sendTerminalResize(); } }, 150); + + // Reposition subagent windows to stack from bottom (above keyboard) + if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows(); }, /** Called when keyboard hides */ @@ -395,6 +426,9 @@ const KeyboardHandler = { this._sendTerminalResize(); } }, 100); + + // Reposition subagent windows to stack from top (below header) + if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows(); }, /** Send current terminal dimensions to the server (one-shot, for keyboard open/close) */ @@ -410,7 +444,7 @@ const KeyboardHandler = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cols, rows }) - }); + }).catch(() => {}); } } catch {} }, @@ -684,7 +718,7 @@ const KeyboardAccessoryBar = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input: escapeSequence }) - }); + }).catch(() => {}); }, /** Read clipboard and send contents as input */ @@ -1089,7 +1123,7 @@ class NotificationManager { const readClass = n.read ? '' : ' unread'; const countLabel = n.count > 1 ? `×${n.count}` : ''; const sessionChip = n.sessionName ? `${this.escapeHtml(n.sessionName)}` : ''; - return `
+ return `
${this.escapeHtml(n.title)}${countLabel} ${this.relativeTime(n.timestamp)} @@ -1719,7 +1753,7 @@ class ClaudemanApp { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cols, rows }) - }); + }).catch(() => {}); } } } @@ -3014,7 +3048,7 @@ class ClaudemanApp { const data = JSON.parse(e.data); const activity = this.subagentActivity.get(data.agentId) || []; activity.push({ type: 'tool', ...data }); - if (activity.length > 100) activity.shift(); // Keep last 100 entries + if (activity.length > 50) activity.shift(); // Keep last 50 entries this.subagentActivity.set(data.agentId, activity); if (this.activeSubagentId === data.agentId) { this.renderSubagentDetail(); @@ -3030,7 +3064,7 @@ class ClaudemanApp { const data = JSON.parse(e.data); const activity = this.subagentActivity.get(data.agentId) || []; activity.push({ type: 'progress', ...data }); - if (activity.length > 100) activity.shift(); + if (activity.length > 50) activity.shift(); this.subagentActivity.set(data.agentId, activity); if (this.activeSubagentId === data.agentId) { this.renderSubagentDetail(); @@ -3045,7 +3079,7 @@ class ClaudemanApp { const data = JSON.parse(e.data); const activity = this.subagentActivity.get(data.agentId) || []; activity.push({ type: 'message', ...data }); - if (activity.length > 100) activity.shift(); + if (activity.length > 50) activity.shift(); this.subagentActivity.set(data.agentId, activity); if (this.activeSubagentId === data.agentId) { this.renderSubagentDetail(); @@ -3058,16 +3092,21 @@ class ClaudemanApp { addListener('subagent:tool_result', (e) => { const data = JSON.parse(e.data); - // Store tool result by toolUseId for later lookup + // Store tool result by toolUseId for later lookup (cap at 50 per agent) if (!this.subagentToolResults.has(data.agentId)) { this.subagentToolResults.set(data.agentId, new Map()); } - this.subagentToolResults.get(data.agentId).set(data.toolUseId, data); + const resultsMap = this.subagentToolResults.get(data.agentId); + resultsMap.set(data.toolUseId, data); + if (resultsMap.size > 50) { + const oldest = resultsMap.keys().next().value; + resultsMap.delete(oldest); + } // Add to activity stream const activity = this.subagentActivity.get(data.agentId) || []; activity.push({ type: 'tool_result', ...data }); - if (activity.length > 100) activity.shift(); + if (activity.length > 50) activity.shift(); this.subagentActivity.set(data.agentId, activity); if (this.activeSubagentId === data.agentId) { @@ -3373,6 +3412,12 @@ class ClaudemanApp { this.writeFrameScheduled = false; // Clear pending hooks this.pendingHooks.clear(); + // Clear subagent activity/results maps (prevents leaks if data.subagents is missing) + this.subagentActivity.clear(); + this.subagentToolResults.clear(); + // Clean up mobile/keyboard handlers before potential re-init + MobileDetection.cleanup(); + KeyboardHandler.cleanup(); // Clear tab alerts this.tabAlerts.clear(); // Clear shown completions (used for duplicate notification prevention) @@ -3674,14 +3719,14 @@ class ClaudemanApp { const minimizedCount = minimizedAgents?.size || 0; const subagentBadge = minimizedCount > 0 ? this.renderSubagentTabBadge(id, minimizedAgents) : ''; - parts.push(` ${parentHeader} @@ -12576,14 +12625,9 @@ class ClaudemanApp { // If we have a parent tab, start window at tab position for spawn animation if (isMobile) { - // Mobile: position as stacked card via inline bottom offset - let visibleCount = 0; - for (const [, data] of this.subagentWindows) { - if (!data.minimized && !data.hidden) visibleCount++; - } - const bottomPx = 40 + visibleCount * (mobileCardHeight + mobileCardGap); - win.style.bottom = `${bottomPx}px`; - win.style.top = 'auto'; + // Mobile: position using top (keyboard-aware positioning calculated above) + win.style.top = `${finalY}px`; + win.style.bottom = 'auto'; } else if (parentTab) { const tabRect = parentTab.getBoundingClientRect(); win.style.left = `${tabRect.left}px`; @@ -12709,6 +12753,34 @@ class ClaudemanApp { // Persist the state change this.saveSubagentWindowStates(); this.updateConnectionLines(); + // Restack remaining visible mobile windows to fill the gap + this.relayoutMobileSubagentWindows(); + } + + /** Reposition all visible mobile subagent windows (called on keyboard show/hide). */ + relayoutMobileSubagentWindows() { + if (MobileDetection.getDeviceType() !== 'mobile') return; + const mobileCardHeight = 110; + const mobileCardGap = 4; + const keyboardUp = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible; + let idx = 0; + for (const [, data] of this.subagentWindows) { + if (data.minimized || data.hidden) continue; + const el = data.element; + if (keyboardUp) { + // Stack from bottom above toolbar + const bottomPx = 40 + idx * (mobileCardHeight + mobileCardGap); + el.style.bottom = `${bottomPx}px`; + el.style.top = 'auto'; + } else { + // Stack from top below header + const headerHeight = document.querySelector('.header')?.offsetHeight || 36; + const topPx = headerHeight + 8 + idx * (mobileCardHeight + mobileCardGap); + el.style.top = `${topPx}px`; + el.style.bottom = 'auto'; + } + idx++; + } } // Close all subagent windows for a session (fully removes them, not minimize) @@ -12951,6 +13023,8 @@ class ClaudemanApp { } windowData.minimized = false; this.updateConnectionLines(); + // Restack all visible mobile windows so restored ones don't overlap + this.relayoutMobileSubagentWindows(); } } @@ -13385,7 +13459,7 @@ class ClaudemanApp { terminal
- +
@@ -13881,7 +13955,7 @@ class ClaudemanApp { const fileName = path.split('/').pop(); html.push(` ${this.escapeHtml(fileName)} `); } @@ -13939,7 +14013,7 @@ class ClaudemanApp { } } catch (err) { console.error('Failed to load file browser:', err); - treeEl.innerHTML = `
Failed to load files: ${err.message}
`; + treeEl.innerHTML = `
Failed to load files: ${this.escapeHtml(err.message)}
`; } } @@ -14143,7 +14217,7 @@ class ClaudemanApp { } } catch (err) { console.error('Failed to preview file:', err); - bodyEl.innerHTML = `
Error: ${err.message}
`; + bodyEl.innerHTML = `
Error: ${this.escapeHtml(err.message)}
`; } } @@ -14250,7 +14324,7 @@ class ClaudemanApp { streaming
- +
@@ -14419,14 +14493,14 @@ class ClaudemanApp { ${sizeKB} KB
- - + +
${this.escapeHtml(fileName)} + onclick="app.openImageInNewTab('${this.escapeHtml(imageUrl)}')" />
`; @@ -14923,7 +14997,7 @@ class ClaudemanApp {
- +
`; @@ -14966,7 +15040,7 @@ class ClaudemanApp {
- ${agent.status !== 'completed' ? `` : ''} + ${agent.status !== 'completed' ? `` : ''}
`; diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 07fa1ea1..7ccfc01c 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -94,23 +94,28 @@ export const RespawnConfigSchema = z.object({ interStepDelayMs: z.number().int().min(100).max(60000).optional(), enabled: z.boolean().optional(), sendClear: z.boolean().optional(), - sendUpdate: z.boolean().optional(), sendInit: z.boolean().optional(), - sendKickstart: z.boolean().optional(), kickstartPrompt: z.string().max(10000).optional(), - aiIdleCheckEnabled: z.boolean().optional(), - aiIdleCheckTimeoutMs: z.number().int().min(10000).max(300000).optional(), - aiIdleCheckModel: z.string().max(100).optional(), completionConfirmMs: z.number().int().min(1000).max(60000).optional(), noOutputTimeoutMs: z.number().int().min(5000).max(600000).optional(), - maxIterations: z.number().int().min(0).max(10000).optional(), - stuckStateWarningMs: z.number().int().min(60000).max(3600000).optional(), - autoAcceptEnabled: z.boolean().optional(), + autoAcceptPrompts: z.boolean().optional(), autoAcceptDelayMs: z.number().int().min(1000).max(60000).optional(), - planModeEnabled: z.boolean().optional(), - planCheckTimeoutMs: z.number().int().min(10000).max(300000).optional(), - planCheckModel: z.string().max(100).optional(), -}).strict(); + aiIdleCheckEnabled: z.boolean().optional(), + aiIdleCheckModel: z.string().max(100).optional(), + aiIdleCheckMaxContext: z.number().int().min(1000).max(500000).optional(), + aiIdleCheckTimeoutMs: z.number().int().min(10000).max(300000).optional(), + aiIdleCheckCooldownMs: z.number().int().min(1000).max(300000).optional(), + aiPlanCheckEnabled: z.boolean().optional(), + aiPlanCheckModel: z.string().max(100).optional(), + aiPlanCheckMaxContext: z.number().int().min(1000).max(500000).optional(), + aiPlanCheckTimeoutMs: z.number().int().min(10000).max(300000).optional(), + aiPlanCheckCooldownMs: z.number().int().min(1000).max(300000).optional(), + adaptiveTimingEnabled: z.boolean().optional(), + adaptiveMinConfirmMs: z.number().int().min(1000).max(60000).optional(), + adaptiveMaxConfirmMs: z.number().int().min(1000).max(600000).optional(), + skipClearWhenLowContext: z.boolean().optional(), + skipClearThresholdPercent: z.number().int().min(0).max(100).optional(), +}); /** * Schema for PUT /api/config @@ -253,3 +258,27 @@ export const CpuLimitSchema = z.object({ ioClass: z.enum(['idle', 'best-effort', 'realtime']).optional(), ioLevel: z.number().int().min(0).max(7).optional(), }); + +/** PUT /api/execution/model-config */ +export const ModelConfigUpdateSchema = z.record(z.string(), z.unknown()); + +/** PUT /api/subagent-window-states */ +export const SubagentWindowStatesSchema = z.object({ + minimized: z.record(z.string(), z.boolean()).optional(), + open: z.array(z.string()).optional(), +}).passthrough(); + +/** PUT /api/subagent-parents */ +export const SubagentParentMapSchema = z.record(z.string(), z.string()); + +/** POST /api/sessions/:id/interactive-respawn */ +export const InteractiveRespawnSchema = z.object({ + respawnConfig: RespawnConfigSchema.optional(), + durationMinutes: z.number().int().min(1).max(14400).optional(), +}); + +/** POST /api/sessions/:id/respawn/enable */ +export const RespawnEnableSchema = z.object({ + config: RespawnConfigSchema.optional(), + durationMinutes: z.number().int().min(1).max(14400).optional(), +}); diff --git a/src/web/server.ts b/src/web/server.ts index 19d2bfa5..89dfb117 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -56,7 +56,7 @@ import { import { CreateSessionSchema, RunPromptSchema, - SessionInputSchema, + SessionInputWithLimitSchema, ResizeSchema, CreateCaseSchema, QuickStartSchema, @@ -81,6 +81,12 @@ import { PlanTaskUpdateSchema, PlanTaskAddSchema, CpuLimitSchema, + SettingsUpdateSchema, + ModelConfigUpdateSchema, + SubagentWindowStatesSchema, + SubagentParentMapSchema, + InteractiveRespawnSchema, + RespawnEnableSchema, } from './schemas.js'; import { StaleExpirationMap } from '../utils/index.js'; @@ -349,7 +355,7 @@ export class WebServer extends EventEmitter { private testMode: boolean; private mux: TerminalMultiplexer; // Terminal batching for performance - private terminalBatches: Map = new Map(); + private terminalBatches: Map = new Map(); private terminalBatchTimer: NodeJS.Timeout | null = null; // Adaptive batching: track rapid events to extend batch window (per-session) // StaleExpirationMap auto-cleans entries for sessions that stop generating output @@ -536,6 +542,16 @@ export class WebServer extends EventEmitter { threshold: 1024, }); + // Security headers on every response + this.app.addHook('onRequest', (_req, reply, done) => { + reply.header('X-Content-Type-Options', 'nosniff'); + reply.header('X-Frame-Options', 'SAMEORIGIN'); + if (this.https) { + reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + done(); + }); + // Serve static files with caching headers for immutable CDN-like assets await this.app.register(fastifyStatic, { root: join(__dirname, 'public'), @@ -712,7 +728,7 @@ export class WebServer extends EventEmitter { // Write env overrides to .claude/settings.local.json if provided if (body.envOverrides && Object.keys(body.envOverrides).length > 0) { - updateCaseEnvVars(workingDir, body.envOverrides); + await updateCaseEnvVars(workingDir, body.envOverrides); } const globalNice = await this.getGlobalNiceConfig(); @@ -738,7 +754,11 @@ export class WebServer extends EventEmitter { // Rename a session this.app.put('/api/sessions/:id/name', async (req) => { const { id } = req.params as { id: string }; - const body = SessionNameSchema.parse(req.body); + const result = SessionNameSchema.safeParse(req.body); + if (!result.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const body = result.data; const session = this.sessions.get(id); if (!session) { @@ -757,7 +777,11 @@ export class WebServer extends EventEmitter { // Set session color this.app.put('/api/sessions/:id/color', async (req) => { const { id } = req.params as { id: string }; - const body = SessionColorSchema.parse(req.body); + const result = SessionColorSchema.safeParse(req.body); + if (!result.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const body = result.data; const session = this.sessions.get(id); if (!session) { @@ -1216,12 +1240,16 @@ export class WebServer extends EventEmitter { // Configure Ralph (Ralph Wiggum) settings this.app.post('/api/sessions/:id/ralph-config', async (req) => { const { id } = req.params as { id: string }; - const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = RalphConfigSchema.parse(req.body) as { + const ralphResult = RalphConfigSchema.safeParse(req.body); + if (!ralphResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = ralphResult.data as { enabled?: boolean; completionPhrase?: string; maxIterations?: number; - reset?: boolean | 'full'; // true = soft reset (keep enabled), 'full' = complete reset - disableAutoEnable?: boolean; // Prevent auto-enable on pattern detection + reset?: boolean | 'full'; + disableAutoEnable?: boolean; }; const session = this.sessions.get(id); @@ -1339,17 +1367,17 @@ export class WebServer extends EventEmitter { // Import todos from @fix_plan.md content this.app.post('/api/sessions/:id/fix-plan/import', async (req) => { const { id } = req.params as { id: string }; - const { content } = FixPlanImportSchema.parse(req.body); + const importResult = FixPlanImportSchema.safeParse(req.body); + if (!importResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const { content } = importResult.data; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } - if (!content || typeof content !== 'string') { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Content is required'); - } - const importedCount = session.ralphTracker.importFixPlanMarkdown(content); this.persistSessionState(session); @@ -1434,7 +1462,11 @@ export class WebServer extends EventEmitter { // This avoids screen input escaping issues with long multi-line prompts this.app.post('/api/sessions/:id/ralph-prompt/write', async (req) => { const { id } = req.params as { id: string }; - const { content } = RalphPromptWriteSchema.parse(req.body); + const promptResult = RalphPromptWriteSchema.safeParse(req.body); + if (!promptResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const { content } = promptResult.data; const session = this.sessions.get(id); if (!session) { @@ -1446,10 +1478,6 @@ export class WebServer extends EventEmitter { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Session has no working directory'); } - if (!content || typeof content !== 'string') { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Content is required'); - } - const filePath = path.join(workingDir, '@ralph_prompt.md'); try { @@ -1552,7 +1580,7 @@ export class WebServer extends EventEmitter { // useScreen: true uses writeViaScreen which is more reliable for programmatic input this.app.post('/api/sessions/:id/input', async (req): Promise => { const { id } = req.params as { id: string }; - const result = SessionInputSchema.safeParse(req.body); + const result = SessionInputWithLimitSchema.safeParse(req.body); if (!result.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); } @@ -1697,7 +1725,14 @@ export class WebServer extends EventEmitter { // Start respawn controller for a session this.app.post('/api/sessions/:id/respawn/start', async (req) => { const { id } = req.params as { id: string }; - const body = req.body ? RespawnConfigSchema.parse(req.body) as Partial : undefined; + let body: Partial | undefined; + if (req.body) { + const result = RespawnConfigSchema.safeParse(req.body); + if (!result.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid respawn config'); + } + body = result.data as Partial; + } const session = this.sessions.get(id); if (!session) { @@ -1823,9 +1858,11 @@ export class WebServer extends EventEmitter { // Start interactive session WITH respawn enabled this.app.post('/api/sessions/:id/interactive-respawn', async (req) => { const { id } = req.params as { id: string }; - const body = req.body as { respawnConfig?: Partial; durationMinutes?: number } | undefined; - // Validate respawn config if present - if (body?.respawnConfig) RespawnConfigSchema.parse(body.respawnConfig); + const irResult = req.body ? InteractiveRespawnSchema.safeParse(req.body) : { success: true as const, data: {} }; + if (!irResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const body = irResult.data as { respawnConfig?: Partial; durationMinutes?: number }; const session = this.sessions.get(id); if (!session) { @@ -1881,9 +1918,11 @@ export class WebServer extends EventEmitter { // Enable respawn on an EXISTING interactive session this.app.post('/api/sessions/:id/respawn/enable', async (req) => { const { id } = req.params as { id: string }; - const body = req.body as { config?: Partial; durationMinutes?: number } | undefined; - // Validate respawn config if present - if (body?.config) RespawnConfigSchema.parse(body.config); + const reResult = req.body ? RespawnEnableSchema.safeParse(req.body) : { success: true as const, data: {} }; + if (!reResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const body = reResult.data as { config?: Partial; durationMinutes?: number }; const session = this.sessions.get(id); if (!session) { @@ -1930,21 +1969,17 @@ export class WebServer extends EventEmitter { // Set auto-clear on a session this.app.post('/api/sessions/:id/auto-clear', async (req) => { const { id } = req.params as { id: string }; - const body = AutoClearSchema.parse(req.body); + const acResult = AutoClearSchema.safeParse(req.body); + if (!acResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const body = acResult.data; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } - if (body.enabled === undefined) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'enabled field is required'); - } - - if (body.threshold !== undefined && (typeof body.threshold !== 'number' || body.threshold < 0)) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'threshold must be a positive number'); - } - session.setAutoClear(body.enabled, body.threshold); this.persistSessionState(session); this.broadcast('session:updated', this.getSessionStateWithRespawn(session)); @@ -1963,21 +1998,17 @@ export class WebServer extends EventEmitter { // Set auto-compact on a session this.app.post('/api/sessions/:id/auto-compact', async (req) => { const { id } = req.params as { id: string }; - const body = AutoCompactSchema.parse(req.body); + const compactResult = AutoCompactSchema.safeParse(req.body); + if (!compactResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const body = compactResult.data; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } - if (body.enabled === undefined) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'enabled field is required'); - } - - if (body.threshold !== undefined && (typeof body.threshold !== 'number' || body.threshold < 0)) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'threshold must be a positive number'); - } - session.setAutoCompact(body.enabled, body.threshold, body.prompt); this.persistSessionState(session); this.broadcast('session:updated', this.getSessionStateWithRespawn(session)); @@ -1997,17 +2028,17 @@ export class WebServer extends EventEmitter { // Toggle image watcher for a session this.app.post('/api/sessions/:id/image-watcher', async (req) => { const { id } = req.params as { id: string }; - const body = ImageWatcherSchema.parse(req.body); + const iwResult = ImageWatcherSchema.safeParse(req.body); + if (!iwResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const body = iwResult.data; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } - if (body.enabled === undefined) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'enabled field is required'); - } - if (body.enabled) { imageWatcher.watchSession(session.id, session.workingDir); } else { @@ -2029,17 +2060,17 @@ export class WebServer extends EventEmitter { // Toggle flicker filter for a session this.app.post('/api/sessions/:id/flicker-filter', async (req) => { const { id } = req.params as { id: string }; - const body = FlickerFilterSchema.parse(req.body); + const ffResult = FlickerFilterSchema.safeParse(req.body); + if (!ffResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const body = ffResult.data; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } - if (body.enabled === undefined) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'enabled field is required'); - } - session.flickerFilterEnabled = body.enabled; this.persistSessionState(session); this.broadcast('session:updated', this.getSessionStateWithRespawn(session)); @@ -2059,7 +2090,11 @@ export class WebServer extends EventEmitter { return createErrorResponse(ApiErrorCode.SESSION_BUSY, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`); } - const { prompt, workingDir } = QuickRunSchema.parse(req.body); + const qrResult = QuickRunSchema.safeParse(req.body); + if (!qrResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const { prompt, workingDir } = qrResult.data; if (!prompt.trim()) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required'); @@ -2091,8 +2126,12 @@ export class WebServer extends EventEmitter { return Array.from(this.scheduledRuns.values()); }); - this.app.post('/api/scheduled', async (req): Promise<{ success: boolean; run: ScheduledRun }> => { - const { prompt, workingDir, durationMinutes } = ScheduledRunSchema.parse(req.body); + this.app.post('/api/scheduled', async (req): Promise<{ success: boolean; run: ScheduledRun } | ApiResponse> => { + const srResult = ScheduledRunSchema.safeParse(req.body); + if (!srResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const { prompt, workingDir, durationMinutes } = srResult.data; const run = await this.startScheduledRun(prompt, workingDir || process.cwd(), durationMinutes ?? 60); return { success: true, run }; @@ -2128,8 +2167,8 @@ export class WebServer extends EventEmitter { const cases: CaseInfo[] = []; // Get cases from casesDir - if (existsSync(casesDir)) { - const entries = readdirSync(casesDir, { withFileTypes: true }); + try { + const entries = await fs.readdir(casesDir, { withFileTypes: true }); for (const e of entries) { if (e.isDirectory()) { cases.push({ @@ -2139,6 +2178,8 @@ export class WebServer extends EventEmitter { }); } } + } catch { + // casesDir may not exist yet } // Get linked cases @@ -2195,7 +2236,7 @@ export class WebServer extends EventEmitter { writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd); // Write .claude/settings.local.json with hooks for desktop notifications - writeHooksConfig(casePath); + await writeHooksConfig(casePath); this.broadcast('case:created', { name, path: casePath }); @@ -2207,15 +2248,11 @@ export class WebServer extends EventEmitter { // Link an existing folder as a case this.app.post('/api/cases/link', async (req): Promise> => { - const { name, path: folderPath } = LinkCaseSchema.parse(req.body); - - if (!name || !/^[a-zA-Z0-9_-]+$/.test(name)) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name. Use only letters, numbers, hyphens, underscores.'); - } - - if (!folderPath) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Folder path is required.'); + const lcResult = LinkCaseSchema.safeParse(req.body); + if (!lcResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); } + const { name, path: folderPath } = lcResult.data; // Expand ~ to home directory const expandedPath = folderPath.startsWith('~') @@ -2256,7 +2293,7 @@ export class WebServer extends EventEmitter { if (!existsSync(claudemanDir)) { mkdirSync(claudemanDir, { recursive: true }); } - writeFileSync(linkedCasesFile, JSON.stringify(linkedCases, null, 2)); + await fs.writeFile(linkedCasesFile, JSON.stringify(linkedCases, null, 2)); this.broadcast('case:linked', { name, path: expandedPath }); return { success: true, data: { case: { name, path: expandedPath } } }; } catch (err) { @@ -2408,12 +2445,12 @@ export class WebServer extends EventEmitter { this.app.post('/api/quick-start', async (req): Promise => { // Prevent unbounded session creation if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) { - return { success: false, error: `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.` }; + return createErrorResponse(ApiErrorCode.SESSION_BUSY, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.`); } const result = QuickStartSchema.safeParse(req.body); if (!result.success) { - return { success: false, error: result.error.issues[0]?.message ?? 'Validation failed' }; + return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); } const { caseName = 'testcase', mode = 'claude' } = result.data; @@ -2424,7 +2461,7 @@ export class WebServer extends EventEmitter { const resolvedBase = resolve(casesDir); const relPath = relative(resolvedBase, resolvedPath); if (relPath.startsWith('..') || isAbsolute(relPath)) { - return { success: false, error: 'Invalid case path' }; + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path'); } // Create case folder and CLAUDE.md if it doesn't exist @@ -2439,11 +2476,11 @@ export class WebServer extends EventEmitter { writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd); // Write .claude/settings.local.json with hooks for desktop notifications - writeHooksConfig(casePath); + await writeHooksConfig(casePath); this.broadcast('case:created', { name: caseName, path: casePath }); } catch (err) { - return { success: false, error: `Failed to create case: ${getErrorMessage(err)}` }; + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`); } } @@ -2518,7 +2555,7 @@ export class WebServer extends EventEmitter { } catch (err) { // Clean up session on error to prevent orphaned resources await this.cleanupSession(session.id); - return { success: false, error: getErrorMessage(err) }; + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } }); @@ -2526,18 +2563,14 @@ export class WebServer extends EventEmitter { type PlanItem = import('../plan-orchestrator.js').PlanItem; this.app.post('/api/generate-plan', async (req): Promise => { + const gpResult = GeneratePlanSchema.safeParse(req.body); + if (!gpResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } const { taskDescription, detailLevel = 'standard' - } = GeneratePlanSchema.parse(req.body); - - if (!taskDescription || typeof taskDescription !== 'string') { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Task description is required'); - } - - if (taskDescription.length > 10000) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Task description too long (max 10000 chars)'); - } + } = gpResult.data; // Build sophisticated prompt based on Ralph Wiggum methodology const detailConfig = { @@ -2718,15 +2751,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Generate detailed implementation plan using subagent orchestration // This spawns multiple specialist subagents in parallel for thorough analysis this.app.post('/api/generate-plan-detailed', async (req): Promise => { - const { taskDescription, caseName } = GeneratePlanDetailedSchema.parse(req.body); - - if (!taskDescription || typeof taskDescription !== 'string') { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Task description is required'); - } - - if (taskDescription.length > 10000) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Task description too long (max 10000 chars)'); + const gpdResult = GeneratePlanDetailedSchema.safeParse(req.body); + if (!gpdResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); } + const { taskDescription, caseName } = gpdResult.data; // Determine output directory for saving wizard results let outputDir: string | undefined; @@ -2822,7 +2851,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Cancel active plan generation this.app.post('/api/cancel-plan-generation', async (req): Promise => { - const { orchestratorId } = CancelPlanSchema.parse(req.body); + const cpResult = CancelPlanSchema.safeParse(req.body); + if (!cpResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const { orchestratorId } = cpResult.data; // If specific orchestrator ID provided, cancel just that one if (orchestratorId) { @@ -2978,7 +3011,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); } - const update = PlanTaskUpdateSchema.parse(req.body) as { + const ptuResult = PlanTaskUpdateSchema.safeParse(req.body); + if (!ptuResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const update = ptuResult.data as { status?: 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked'; error?: string; incrementAttempts?: boolean; @@ -3062,7 +3099,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); } - const task = PlanTaskAddSchema.parse(req.body); + const ptaResult = PlanTaskAddSchema.safeParse(req.body); + if (!ptaResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const task = ptaResult.data; const result = tracker.addPlanTask(task); this.broadcast('session:planTaskAdded', { sessionId: id, task: result.task }); @@ -3085,7 +3126,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; }); this.app.put('/api/settings', async (req) => { - const settings = req.body as Record; + const settingsResult = SettingsUpdateSchema.safeParse(req.body); + if (!settingsResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid settings'); + } + const settings = settingsResult.data as Record; try { const dir = dirname(settingsPath); @@ -3093,9 +3138,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; mkdirSync(dir, { recursive: true }); } let existing: Record = {}; - try { existing = JSON.parse(readFileSync(settingsPath, 'utf-8')); } catch { /* ignore */ } + try { existing = JSON.parse(await fs.readFile(settingsPath, 'utf-8')); } catch { /* ignore */ } const merged = { ...existing, ...settings }; - writeFileSync(settingsPath, JSON.stringify(merged, null, 2)); + await fs.writeFile(settingsPath, JSON.stringify(merged, null, 2)); // Handle subagent tracking toggle dynamically const subagentEnabled = settings.subagentTrackingEnabled ?? true; @@ -3145,7 +3190,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; }); this.app.put('/api/execution/model-config', async (req) => { - const modelConfig = req.body as Record; + const mcResult = ModelConfigUpdateSchema.safeParse(req.body); + if (!mcResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid model config'); + } + const modelConfig = mcResult.data as Record; try { let settings: Record = {}; @@ -3162,7 +3211,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); return { success: true }; } catch (err) { @@ -3194,14 +3243,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } - const body = CpuLimitSchema.parse(req.body) as Partial; - - // Validate inputs - if (body.niceValue !== undefined) { - if (typeof body.niceValue !== 'number' || body.niceValue < -20 || body.niceValue > 19) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Nice value must be between -20 and 19'); - } + const clResult = CpuLimitSchema.safeParse(req.body); + if (!clResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); } + const body = clResult.data as Partial; session.setNice(body); this.persistSessionState(session); @@ -3231,13 +3277,17 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; }); this.app.put('/api/subagent-window-states', async (req) => { - const states = req.body as Record; + const swResult = SubagentWindowStatesSchema.safeParse(req.body); + if (!swResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid window states'); + } + const states = swResult.data as Record; try { const dir = dirname(windowStatesPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - writeFileSync(windowStatesPath, JSON.stringify(states, null, 2)); + await fs.writeFile(windowStatesPath, JSON.stringify(states, null, 2)); return { success: true }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); @@ -3262,13 +3312,17 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; }); this.app.put('/api/subagent-parents', async (req) => { - const parentMap = req.body as Record; + const spResult = SubagentParentMapSchema.safeParse(req.body); + if (!spResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid parent map'); + } + const parentMap = spResult.data; try { const dir = dirname(parentMapPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - writeFileSync(parentMapPath, JSON.stringify(parentMap, null, 2)); + await fs.writeFile(parentMapPath, JSON.stringify(parentMap, null, 2)); return { success: true }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); @@ -3517,7 +3571,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19); const filename = `screenshot_${timestamp}${ext}`; const filepath = join(SCREENSHOTS_DIR, filename); - writeFileSync(filepath, filePart.data); + await fs.writeFile(filepath, filePart.data); return { success: true, path: filepath, filename }; }); @@ -4669,9 +4723,13 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Skip if server is stopping if (this._isStopping) return; - const existing = this.terminalBatches.get(sessionId) || ''; - const newBatch = existing + data; - this.terminalBatches.set(sessionId, newBatch); + let chunks = this.terminalBatches.get(sessionId); + if (!chunks) { + chunks = []; + this.terminalBatches.set(sessionId, chunks); + } + chunks.push(data); + const totalLength = chunks.reduce((sum, c) => sum + c.length, 0); // Adaptive batching: detect rapid events and extend batch window (per-session) const now = Date.now(); @@ -4696,7 +4754,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } // Flush immediately if batch is large for responsiveness - if (newBatch.length > BATCH_FLUSH_THRESHOLD) { + if (totalLength > BATCH_FLUSH_THRESHOLD) { if (this.terminalBatchTimer) { clearTimeout(this.terminalBatchTimer); this.terminalBatchTimer = null; @@ -4724,8 +4782,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; this.terminalBatches.clear(); return; } - for (const [sessionId, data] of this.terminalBatches) { - if (data.length > 0) { + 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(''); // 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.