chore: bump version to 0.1528

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-02-18 07:06:34 +01:00
co-authored by Claude Opus 4.6
parent 9d67a571ba
commit 31d3e7bc70
14 changed files with 540 additions and 303 deletions
+1 -1
View File
@@ -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`) 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` 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 ## Project Overview
+3 -2
View File
@@ -1,9 +1,10 @@
{ {
"name": "claudeman", "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", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": { "bin": {
"claudeman": "./dist/index.js" "claudeman": "./dist/index.js"
}, },
@@ -33,7 +34,7 @@
"autonomous-agent", "autonomous-agent",
"ralph-loop" "ralph-loop"
], ],
"author": "", "author": "arkon",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fastify/compress": "^8.3.1", "@fastify/compress": "^8.3.1",
+5 -1
View File
@@ -9,18 +9,22 @@
import { Command } from 'commander'; import { Command } from 'commander';
import chalk from 'chalk'; import chalk from 'chalk';
import { createRequire } from 'module';
import { getSessionManager } from './session-manager.js'; import { getSessionManager } from './session-manager.js';
import { getTaskQueue } from './task-queue.js'; import { getTaskQueue } from './task-queue.js';
import { getRalphLoop } from './ralph-loop.js'; import { getRalphLoop } from './ralph-loop.js';
import { getStore } from './state-store.js'; import { getStore } from './state-store.js';
import { getErrorMessage } from './types.js'; import { getErrorMessage } from './types.js';
const require = createRequire(import.meta.url);
const pkg = require('../package.json') as { version: string };
const program = new Command(); const program = new Command();
program program
.name('claudeman') .name('claudeman')
.description('Claude Code session manager with autonomous Ralph Loop') .description('Claude Code session manager with autonomous Ralph Loop')
.version('1.0.0'); .version(pkg.version);
// ============ Session Commands ============ // ============ Session Commands ============
+17 -20
View File
@@ -8,7 +8,8 @@
* config is static per case directory. * 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 { join } from 'node:path';
import type { HookEventType } from './types.js'; import type { HookEventType } from './types.js';
@@ -70,21 +71,19 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
* Updates env vars in .claude/settings.local.json for the given case path. * Updates env vars in .claude/settings.local.json for the given case path.
* Merges with existing env field; removes vars set to empty string. * Merges with existing env field; removes vars set to empty string.
*/ */
export function updateCaseEnvVars(casePath: string, envVars: Record<string, string>): void { export async function updateCaseEnvVars(casePath: string, envVars: Record<string, string>): Promise<void> {
const claudeDir = join(casePath, '.claude'); const claudeDir = join(casePath, '.claude');
if (!existsSync(claudeDir)) { if (!existsSync(claudeDir)) {
mkdirSync(claudeDir, { recursive: true }); await mkdir(claudeDir, { recursive: true });
} }
const settingsPath = join(claudeDir, 'settings.local.json'); const settingsPath = join(claudeDir, 'settings.local.json');
let existing: Record<string, unknown> = {}; let existing: Record<string, unknown> = {};
if (existsSync(settingsPath)) { try {
try { existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
existing = JSON.parse(readFileSync(settingsPath, 'utf-8')); } catch {
} catch { existing = {};
existing = {};
}
} }
const currentEnv = (existing.env as Record<string, string>) || {}; const currentEnv = (existing.env as Record<string, string>) || {};
@@ -97,33 +96,31 @@ export function updateCaseEnvVars(casePath: string, envVars: Record<string, stri
} }
existing.env = currentEnv; existing.env = currentEnv;
writeFileSync(settingsPath, JSON.stringify(existing, null, 2) + '\n'); await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
} }
/** /**
* Writes hooks config to .claude/settings.local.json in the given case path. * Writes hooks config to .claude/settings.local.json in the given case path.
* Merges with existing file content, only touching the `hooks` key. * Merges with existing file content, only touching the `hooks` key.
*/ */
export function writeHooksConfig(casePath: string): void { export async function writeHooksConfig(casePath: string): Promise<void> {
const claudeDir = join(casePath, '.claude'); const claudeDir = join(casePath, '.claude');
if (!existsSync(claudeDir)) { if (!existsSync(claudeDir)) {
mkdirSync(claudeDir, { recursive: true }); await mkdir(claudeDir, { recursive: true });
} }
const settingsPath = join(claudeDir, 'settings.local.json'); const settingsPath = join(claudeDir, 'settings.local.json');
let existing: Record<string, unknown> = {}; let existing: Record<string, unknown> = {};
if (existsSync(settingsPath)) { try {
try { existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
existing = JSON.parse(readFileSync(settingsPath, 'utf-8')); } catch {
} catch { // If file is malformed or doesn't exist, start fresh
// If file is malformed, start fresh existing = {};
existing = {};
}
} }
const hooksConfig = generateHooksConfig(); const hooksConfig = generateHooksConfig();
const merged = { ...existing, ...hooksConfig }; const merged = { ...existing, ...hooksConfig };
writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n'); await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n');
} }
+7 -1
View File
@@ -141,6 +141,7 @@ const EVENT_DEBOUNCE_MS = 50;
* Prevents unbounded growth if many unique phrases are seen. * Prevents unbounded growth if many unique phrases are seen.
*/ */
const MAX_COMPLETION_PHRASE_ENTRIES = 50; const MAX_COMPLETION_PHRASE_ENTRIES = 50;
const MAX_PLAN_HISTORY = 10;
/** /**
* Common/generic completion phrases that may cause false positives. * Common/generic completion phrases that may cause false positives.
@@ -3711,7 +3712,7 @@ export class RalphTracker extends EventEmitter {
}); });
// Limit history size // Limit history size
if (this._planHistory.length > 10) { if (this._planHistory.length > MAX_PLAN_HISTORY) {
this._planHistory.shift(); this._planHistory.shift();
} }
} }
@@ -3846,6 +3847,11 @@ export class RalphTracker extends EventEmitter {
this._alternateCompletionPhrases.clear(); this._alternateCompletionPhrases.clear();
this._completionPhraseCount.clear(); this._completionPhraseCount.clear();
this._planTasks.clear(); this._planTasks.clear();
this._completionTimes.length = 0;
this._lineBuffer = '';
this._partialPromiseBuffer = '';
this._statusBlockBuffer.length = 0;
this._planHistory.length = 0;
this.removeAllListeners(); this.removeAllListeners();
} }
} }
+33 -13
View File
@@ -1160,6 +1160,7 @@ export class RespawnController extends EventEmitter {
*/ */
private startDetectionUpdates(): void { private startDetectionUpdates(): void {
this.stopDetectionUpdates(); this.stopDetectionUpdates();
if (this._state === 'stopped') return;
this.detectionUpdateTimer = setInterval(() => { this.detectionUpdateTimer = setInterval(() => {
try { try {
if (this._state !== 'stopped') { if (this._state !== 'stopped') {
@@ -1329,7 +1330,12 @@ export class RespawnController extends EventEmitter {
*/ */
resume(): void { resume(): void {
this.log('Resuming respawn'); 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) { if (!this.terminalHandler) {
this.setupTerminalListener(); this.setupTerminalListener();
} }
@@ -1688,6 +1694,7 @@ export class RespawnController extends EventEmitter {
this.config.interStepDelayMs, this.config.interStepDelayMs,
async () => { async () => {
this.stepTimer = null; this.stepTimer = null;
if (this._state === 'stopped') return;
const prompt = this.config.kickstartPrompt!; const prompt = this.config.kickstartPrompt!;
this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`); this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`);
await this.session.writeViaScreen(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI 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) */ /** Clear all timers (idle, step, completion confirm, no-output, pre-filter, step confirm, auto-accept, hook confirm, and clear fallback) */
private clearTimers(): void { private clearTimers(): void {
// Clear tracked timers map first to avoid stale entries during individual cleanup
this.activeTimers.clear();
this.clearIdleTimer(); this.clearIdleTimer();
if (this.stepTimer) { if (this.stepTimer) {
clearTimeout(this.stepTimer); clearTimeout(this.stepTimer);
@@ -1769,8 +1778,6 @@ export class RespawnController extends EventEmitter {
clearInterval(this.detectionUpdateTimer); clearInterval(this.detectionUpdateTimer);
this.detectionUpdateTimer = null; this.detectionUpdateTimer = null;
} }
// Clear all tracked timers
this.activeTimers.clear();
} }
// ========== Stuck-State Detection Methods ========== // ========== Stuck-State Detection Methods ==========
@@ -1901,6 +1908,9 @@ export class RespawnController extends EventEmitter {
this.setState('watching'); this.setState('watching');
this.startNoOutputTimer(); this.startNoOutputTimer();
this.startPreFilterTimer(); this.startPreFilterTimer();
if (this.config.autoAcceptPrompts) {
this.startAutoAcceptTimer();
}
} }
} }
@@ -2237,6 +2247,7 @@ export class RespawnController extends EventEmitter {
if (this._currentAiCheckId !== checkId) { if (this._currentAiCheckId !== checkId) {
return; // Stale check, ignore error return; // Stale check, ignore error
} }
if (this._state === 'stopped') return; // Guard against stopped state
if (this._state === 'ai_checking') { if (this._state === 'ai_checking') {
const errorMsg = err instanceof Error ? err.message : String(err); const errorMsg = err instanceof Error ? err.message : String(err);
this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`); 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 // Output arrived after hook - Claude started new work
this.log(`Output received after ${hookType} hook, cancelling idle confirmation`); this.log(`Output received after ${hookType} hook, cancelling idle confirmation`);
this.logAction('hook', `${hookType} cancelled - new output detected`); 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.setState('watching');
this.resetHookState();
this.startNoOutputTimer(); this.startNoOutputTimer();
this.startPreFilterTimer(); this.startPreFilterTimer();
if (this.config.autoAcceptPrompts) {
this.startAutoAcceptTimer();
}
return; return;
} }
@@ -2645,6 +2660,7 @@ export class RespawnController extends EventEmitter {
this.config.completionConfirmMs, this.config.completionConfirmMs,
() => { () => {
this.completionConfirmTimer = null; this.completionConfirmTimer = null;
if (this._state === 'stopped') return;
const msSinceOutput = Date.now() - this.lastOutputTime; const msSinceOutput = Date.now() - this.lastOutputTime;
if (msSinceOutput >= this.config.completionConfirmMs) { if (msSinceOutput >= this.config.completionConfirmMs) {
this.logAction('detection', `Silence confirmed: ${Math.round(msSinceOutput / 1000)}s`); this.logAction('detection', `Silence confirmed: ${Math.round(msSinceOutput / 1000)}s`);
@@ -2652,6 +2668,7 @@ export class RespawnController extends EventEmitter {
} else { } else {
// Output received during wait, stay in confirming state and re-check // Output received during wait, stay in confirming state and re-check
this.logAction('detection', 'Output during confirmation, resetting'); this.logAction('detection', 'Output during confirmation, resetting');
if (this._state !== 'confirming_idle') return;
this.startCompletionConfirmTimer(); this.startCompletionConfirmTimer();
} }
}, },
@@ -2663,10 +2680,8 @@ export class RespawnController extends EventEmitter {
* Cancel completion confirmation if new activity detected. * Cancel completion confirmation if new activity detected.
*/ */
private cancelCompletionConfirm(): void { private cancelCompletionConfirm(): void {
if (this.completionConfirmTimer) { this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'activity detected');
clearTimeout(this.completionConfirmTimer); this.completionConfirmTimer = null;
this.completionConfirmTimer = null;
}
if (this._state === 'confirming_idle') { if (this._state === 'confirming_idle') {
this.setState('watching'); this.setState('watching');
this.completionMessageTime = null; this.completionMessageTime = null;
@@ -2687,6 +2702,7 @@ export class RespawnController extends EventEmitter {
this.config.completionConfirmMs, this.config.completionConfirmMs,
() => { () => {
this.stepConfirmTimer = null; this.stepConfirmTimer = null;
if (this._state === 'stopped') return;
const msSinceOutput = Date.now() - this.lastOutputTime; const msSinceOutput = Date.now() - this.lastOutputTime;
if (msSinceOutput >= this.config.completionConfirmMs) { if (msSinceOutput >= this.config.completionConfirmMs) {
@@ -2718,11 +2734,8 @@ export class RespawnController extends EventEmitter {
* Cancel step confirmation if working patterns detected. * Cancel step confirmation if working patterns detected.
*/ */
private cancelStepConfirm(): void { private cancelStepConfirm(): void {
if (this.stepConfirmTimer) { this.cancelTrackedTimer('step-confirm', this.stepConfirmTimer, 'working detected');
clearTimeout(this.stepConfirmTimer); this.stepConfirmTimer = null;
this.stepConfirmTimer = null;
this.log(`Step confirmation cancelled (working detected)`);
}
} }
/** /**
@@ -2803,6 +2816,9 @@ export class RespawnController extends EventEmitter {
return; return;
} }
// Clear all detection timers before starting cycle to prevent stale callbacks
this.clearTimers();
// P1-006: Session health check before respawn cycle // P1-006: Session health check before respawn cycle
// Skip if session is in error state or not running // Skip if session is in error state or not running
if (this.session.status === 'error') { if (this.session.status === 'error') {
@@ -2856,6 +2872,7 @@ export class RespawnController extends EventEmitter {
this.config.interStepDelayMs, this.config.interStepDelayMs,
async () => { async () => {
this.stepTimer = null; this.stepTimer = null;
if (this._state === 'stopped') return;
// Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config // Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config
const statusBlock = this.session.ralphTracker?.lastStatusBlock; const statusBlock = this.session.ralphTracker?.lastStatusBlock;
@@ -2895,6 +2912,7 @@ export class RespawnController extends EventEmitter {
this.config.interStepDelayMs, this.config.interStepDelayMs,
async () => { async () => {
this.stepTimer = null; this.stepTimer = null;
if (this._state === 'stopped') return;
this.logAction('command', 'Sending: /clear'); this.logAction('command', 'Sending: /clear');
await this.session.writeViaScreen('/clear\r'); // \r triggers Enter in Ink/Claude CLI await this.session.writeViaScreen('/clear\r'); // \r triggers Enter in Ink/Claude CLI
this.emit('stepSent', 'clear', '/clear'); this.emit('stepSent', 'clear', '/clear');
@@ -2938,6 +2956,7 @@ export class RespawnController extends EventEmitter {
this.config.interStepDelayMs, this.config.interStepDelayMs,
async () => { async () => {
this.stepTimer = null; this.stepTimer = null;
if (this._state === 'stopped') return;
this.logAction('command', 'Sending: /init'); this.logAction('command', 'Sending: /init');
await this.session.writeViaScreen('/init\r'); // \r triggers Enter in Ink/Claude CLI await this.session.writeViaScreen('/init\r'); // \r triggers Enter in Ink/Claude CLI
this.emit('stepSent', 'init', '/init'); this.emit('stepSent', 'init', '/init');
@@ -2985,6 +3004,7 @@ export class RespawnController extends EventEmitter {
* Used when resuming from pause. * Used when resuming from pause.
*/ */
private checkIdleAndMaybeStart(): void { private checkIdleAndMaybeStart(): void {
if (this._state === 'stopped') return;
// Check if already idle // Check if already idle
const timeSinceActivity = Date.now() - this.lastActivityTime; const timeSinceActivity = Date.now() - this.lastActivityTime;
if (timeSinceActivity > this.config.idleTimeoutMs && this.promptDetected) { if (timeSinceActivity > this.config.idleTimeoutMs && this.promptDetected) {
+2 -3
View File
@@ -243,7 +243,6 @@ export class StateStore {
return; return;
} }
this.dirty = false;
this.ensureDir(); this.ensureDir();
const tempPath = this.filePath + '.tmp'; const tempPath = this.filePath + '.tmp';
@@ -259,7 +258,6 @@ export class StateStore {
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly'); console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
this.circuitBreakerOpen = true; this.circuitBreakerOpen = true;
} }
this.dirty = true;
return; return;
} }
@@ -275,6 +273,8 @@ export class StateStore {
try { try {
writeFileSync(tempPath, json, 'utf-8'); writeFileSync(tempPath, json, 'utf-8');
renameSync(tempPath, this.filePath); renameSync(tempPath, this.filePath);
// Clear dirty flag only AFTER successful write
this.dirty = false;
this.consecutiveSaveFailures = 0; this.consecutiveSaveFailures = 0;
if (this.circuitBreakerOpen) { if (this.circuitBreakerOpen) {
console.log('[StateStore] Circuit breaker CLOSED - save succeeded'); console.log('[StateStore] Circuit breaker CLOSED - save succeeded');
@@ -288,7 +288,6 @@ export class StateStore {
console.error('[StateStore] Circuit breaker OPEN - writes failing repeatedly'); console.error('[StateStore] Circuit breaker OPEN - writes failing repeatedly');
this.circuitBreakerOpen = true; this.circuitBreakerOpen = true;
} }
this.dirty = true;
} }
} }
+22 -3
View File
@@ -949,6 +949,7 @@ export class SubagentWatcher extends EventEmitter {
// Store handler reference for proper cleanup // Store handler reference for proper cleanup
const errorHandler = (error: Error) => { const errorHandler = (error: Error) => {
this.emit('subagent:error', error instanceof Error ? error : new Error(String(error))); this.emit('subagent:error', error instanceof Error ? error : new Error(String(error)));
watcher.close();
this.dirWatcherErrorHandlers.delete(dir); this.dirWatcherErrorHandlers.delete(dir);
this.dirWatchers.delete(dir); this.dirWatchers.delete(dir);
this.knownSubagentDirs.delete(dir); this.knownSubagentDirs.delete(dir);
@@ -1019,6 +1020,21 @@ export class SubagentWatcher extends EventEmitter {
description, 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.agentInfo.set(agentId, info);
this.emit('subagent:discovered', info); this.emit('subagent:discovered', info);
@@ -1083,6 +1099,7 @@ export class SubagentWatcher extends EventEmitter {
// Store handler reference for proper cleanup // Store handler reference for proper cleanup
const errorHandler = (error: Error) => { const errorHandler = (error: Error) => {
this.emit('subagent:error', error instanceof Error ? error : new Error(String(error)), agentId); this.emit('subagent:error', error instanceof Error ? error : new Error(String(error)), agentId);
watcher.close();
this.fileWatcherErrorHandlers.delete(filePath); this.fileWatcherErrorHandlers.delete(filePath);
this.fileWatchers.delete(filePath); this.fileWatchers.delete(filePath);
}; };
@@ -1113,11 +1130,13 @@ export class SubagentWatcher extends EventEmitter {
rl.on('line', (line) => { rl.on('line', (line) => {
const lineBytes = Buffer.byteLength(line, 'utf8') + 1; const lineBytes = Buffer.byteLength(line, 'utf8') + 1;
position += lineBytes; // Always advance past the line
try { try {
const entry = JSON.parse(line) as SubagentTranscriptEntry; const entry = JSON.parse(line) as SubagentTranscriptEntry;
this.processEntry(entry, agentId, sessionId); this.processEntry(entry, agentId, sessionId).catch(() => {
position += lineBytes; // Only advance on successful parse // processEntry failure is non-critical
});
// Update entry count // Update entry count
const info = this.agentInfo.get(agentId); const info = this.agentInfo.get(agentId);
@@ -1125,7 +1144,7 @@ export class SubagentWatcher extends EventEmitter {
info.entryCount++; info.entryCount++;
} }
} catch { } catch {
// Don't advance position - will retry on next change event // Malformed JSON line — skip it
} }
}); });
+35 -28
View File
@@ -35,6 +35,8 @@ export class TeamWatcher extends EventEmitter {
private configMtimes: Map<string, number> = new Map(); private configMtimes: Map<string, number> = new Map();
private taskMtimes: Map<string, string> = new Map(); private taskMtimes: Map<string, string> = new Map();
private inboxMtimes: Map<string, number> = new Map(); private inboxMtimes: Map<string, number> = new Map();
// Reverse index: sessionId → teamName for O(1) lookup
private sessionToTeam: Map<string, string> = new Map();
constructor(teamsDir?: string, tasksDir?: string) { constructor(teamsDir?: string, tasksDir?: string) {
super(); super();
@@ -60,6 +62,7 @@ export class TeamWatcher extends EventEmitter {
this.configMtimes.clear(); this.configMtimes.clear();
this.taskMtimes.clear(); this.taskMtimes.clear();
this.inboxMtimes.clear(); this.inboxMtimes.clear();
this.sessionToTeam.clear();
} }
/** Get all discovered teams */ /** Get all discovered teams */
@@ -69,10 +72,12 @@ export class TeamWatcher extends EventEmitter {
/** Get team associated with a Claudeman session (matched by leadSessionId) */ /** Get team associated with a Claudeman session (matched by leadSessionId) */
getTeamForSession(sessionId: string): TeamConfig | undefined { getTeamForSession(sessionId: string): TeamConfig | undefined {
for (const team of this.teams.values()) { const teamName = this.sessionToTeam.get(sessionId);
if (team.leadSessionId === sessionId) { if (teamName) {
return team; const team = this.teams.get(teamName);
} if (team) return team;
// Stale reverse index entry — clean up
this.sessionToTeam.delete(sessionId);
} }
return undefined; return undefined;
} }
@@ -191,6 +196,11 @@ export class TeamWatcher extends EventEmitter {
const existing = this.teams.get(entry); const existing = this.teams.get(entry);
this.teams.set(entry, config); 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) { if (existing) {
this.emit('teamUpdated', config); this.emit('teamUpdated', config);
@@ -205,6 +215,10 @@ export class TeamWatcher extends EventEmitter {
const removed = this.teams.get(name); const removed = this.teams.get(name);
this.teams.delete(name); this.teams.delete(name);
this.configMtimes.delete(name); this.configMtimes.delete(name);
// Clean up reverse index
if (removed) {
this.sessionToTeam.delete(removed.leadSessionId);
}
// Prune stale mtime entries for removed teams // Prune stale mtime entries for removed teams
this.taskMtimes.delete(name); this.taskMtimes.delete(name);
for (const key of Array.from(this.inboxMtimes.keys())) { for (const key of Array.from(this.inboxMtimes.keys())) {
@@ -229,6 +243,18 @@ export class TeamWatcher extends EventEmitter {
for (const teamName of teamDirs) { for (const teamName of teamDirs) {
const teamTaskDir = join(this.tasksDir, teamName); 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[]; let taskFiles: string[];
try { try {
taskFiles = (await readdir(teamTaskDir)).filter(f => f.endsWith('.json') && f !== '.lock'); taskFiles = (await readdir(teamTaskDir)).filter(f => f.endsWith('.json') && f !== '.lock');
@@ -236,25 +262,6 @@ export class TeamWatcher extends EventEmitter {
continue; 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 // Skip if locked
if (await this.isLocked(join(teamTaskDir, '.lock'))) continue; if (await this.isLocked(join(teamTaskDir, '.lock'))) continue;
@@ -306,11 +313,11 @@ export class TeamWatcher extends EventEmitter {
const previous = this.inboxCache.get(cacheKey); const previous = this.inboxCache.get(cacheKey);
this.inboxCache.set(cacheKey, messages); this.inboxCache.set(cacheKey, messages);
// Emit new messages (ones not in previous cache) // Emit new messages — compare by timestamp to handle deletions/reordering
const prevCount = previous?.length || 0; const prevTimestamps = new Set(previous?.map(m => m.timestamp) || []);
if (messages.length > prevCount) { for (const msg of messages) {
for (let i = prevCount; i < messages.length; i++) { if (!prevTimestamps.has(msg.timestamp)) {
this.emit('inboxMessage', { teamName, member: memberName, message: messages[i] }); this.emit('inboxMessage', { teamName, member: memberName, message: msg });
} }
} }
} }
+49 -28
View File
@@ -101,6 +101,7 @@ export class TranscriptWatcher extends EventEmitter {
private pollInterval: NodeJS.Timeout | null = null; private pollInterval: NodeJS.Timeout | null = null;
private filePosition: number = 0; private filePosition: number = 0;
private _isRunning: boolean = false; private _isRunning: boolean = false;
private _isProcessing: boolean = false;
private state: TranscriptState = this.getInitialState(); private state: TranscriptState = this.getInitialState();
constructor() { constructor() {
@@ -252,6 +253,8 @@ export class TranscriptWatcher extends EventEmitter {
private async processNewContent(): Promise<void> { private async processNewContent(): Promise<void> {
if (!this.transcriptPath || !this._isRunning) return; if (!this.transcriptPath || !this._isRunning) return;
if (this._isProcessing) return; // Guard against concurrent calls
this._isProcessing = true;
try { try {
const stat = statSync(this.transcriptPath); const stat = statSync(this.transcriptPath);
@@ -275,6 +278,8 @@ export class TranscriptWatcher extends EventEmitter {
} }
} catch (err) { } catch (err) {
this.emit('transcript:error', err as Error); this.emit('transcript:error', err as Error);
} finally {
this._isProcessing = false;
} }
} }
@@ -286,38 +291,54 @@ export class TranscriptWatcher extends EventEmitter {
} }
const entries: TranscriptEntry[] = []; 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, 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({ const stream = createReadStream(transcriptPath, {
input: stream, start: this.filePosition,
crlfDelay: Infinity, 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);
}); });
rawStream.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);
}); });
} }
+3 -3
View File
@@ -10,7 +10,7 @@
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { delimiter, dirname, join } from 'node:path';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
/** Timeout for exec commands (5 seconds) */ /** Timeout for exec commands (5 seconds) */
@@ -77,8 +77,8 @@ export function getAugmentedPath(): string {
const currentPath = process.env.PATH || ''; const currentPath = process.env.PATH || '';
const claudeDir = findClaudeDir(); const claudeDir = findClaudeDir();
if (claudeDir && !currentPath.split(':').includes(claudeDir)) { if (claudeDir && !currentPath.split(delimiter).includes(claudeDir)) {
_augmentedPath = `${claudeDir}:${currentPath}`; _augmentedPath = `${claudeDir}${delimiter}${currentPath}`;
return _augmentedPath; return _augmentedPath;
} }
+144 -70
View File
@@ -190,10 +190,19 @@ const MobileDetection = {
this.updateBodyClass(); this.updateBodyClass();
// Debounced resize handler // Debounced resize handler
let resizeTimeout; let resizeTimeout;
window.addEventListener('resize', () => { this._resizeHandler = () => {
clearTimeout(resizeTimeout); clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => this.updateBodyClass(), 100); 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; this.lastViewportHeight = this.initialViewportHeight;
// Simple focus handler - scroll input into view after keyboard appears // Simple focus handler - scroll input into view after keyboard appears
document.addEventListener('focusin', (e) => { this._focusinHandler = (e) => {
const target = e.target; const target = e.target;
if (!this.isInputElement(target)) return; if (!this.isInputElement(target)) return;
@@ -243,17 +252,36 @@ const KeyboardHandler = {
setTimeout(() => { setTimeout(() => {
this.scrollInputIntoView(target); this.scrollInputIntoView(target);
}, 400); }, 400);
}); };
document.addEventListener('focusin', this._focusinHandler);
// Use visualViewport to detect keyboard and reposition toolbar // Use visualViewport to detect keyboard and reposition toolbar
if (window.visualViewport) { if (window.visualViewport) {
window.visualViewport.addEventListener('resize', () => { this._viewportResizeHandler = () => {
this.handleViewportResize(); this.handleViewportResize();
}); };
// Also handle scroll (iOS scrolls viewport when keyboard appears) this._viewportScrollHandler = () => {
window.visualViewport.addEventListener('scroll', () => {
this.updateLayoutForKeyboard(); 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(); this._sendTerminalResize();
} }
}, 150); }, 150);
// Reposition subagent windows to stack from bottom (above keyboard)
if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
}, },
/** Called when keyboard hides */ /** Called when keyboard hides */
@@ -395,6 +426,9 @@ const KeyboardHandler = {
this._sendTerminalResize(); this._sendTerminalResize();
} }
}, 100); }, 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) */ /** Send current terminal dimensions to the server (one-shot, for keyboard open/close) */
@@ -410,7 +444,7 @@ const KeyboardHandler = {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols, rows }) body: JSON.stringify({ cols, rows })
}); }).catch(() => {});
} }
} catch {} } catch {}
}, },
@@ -684,7 +718,7 @@ const KeyboardAccessoryBar = {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: escapeSequence }) body: JSON.stringify({ input: escapeSequence })
}); }).catch(() => {});
}, },
/** Read clipboard and send contents as input */ /** Read clipboard and send contents as input */
@@ -1089,7 +1123,7 @@ class NotificationManager {
const readClass = n.read ? '' : ' unread'; const readClass = n.read ? '' : ' unread';
const countLabel = n.count > 1 ? `<span class="notif-item-count">&times;${n.count}</span>` : ''; const countLabel = n.count > 1 ? `<span class="notif-item-count">&times;${n.count}</span>` : '';
const sessionChip = n.sessionName ? `<span class="notif-item-session">${this.escapeHtml(n.sessionName)}</span>` : ''; const sessionChip = n.sessionName ? `<span class="notif-item-session">${this.escapeHtml(n.sessionName)}</span>` : '';
return `<div class="notif-item ${urgencyClass}${readClass}" data-notif-id="${n.id}" data-session-id="${n.sessionId || ''}" onclick="app.notificationManager.clickNotification('${n.id}')"> return `<div class="notif-item ${urgencyClass}${readClass}" data-notif-id="${n.id}" data-session-id="${n.sessionId || ''}" onclick="app.notificationManager.clickNotification('${this.escapeHtml(n.id)}')">
<div class="notif-item-header"> <div class="notif-item-header">
<span class="notif-item-title">${this.escapeHtml(n.title)}${countLabel}</span> <span class="notif-item-title">${this.escapeHtml(n.title)}${countLabel}</span>
<span class="notif-item-time">${this.relativeTime(n.timestamp)}</span> <span class="notif-item-time">${this.relativeTime(n.timestamp)}</span>
@@ -1719,7 +1753,7 @@ class ClaudemanApp {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols, rows }) body: JSON.stringify({ cols, rows })
}); }).catch(() => {});
} }
} }
} }
@@ -3014,7 +3048,7 @@ class ClaudemanApp {
const data = JSON.parse(e.data); const data = JSON.parse(e.data);
const activity = this.subagentActivity.get(data.agentId) || []; const activity = this.subagentActivity.get(data.agentId) || [];
activity.push({ type: 'tool', ...data }); 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); this.subagentActivity.set(data.agentId, activity);
if (this.activeSubagentId === data.agentId) { if (this.activeSubagentId === data.agentId) {
this.renderSubagentDetail(); this.renderSubagentDetail();
@@ -3030,7 +3064,7 @@ class ClaudemanApp {
const data = JSON.parse(e.data); const data = JSON.parse(e.data);
const activity = this.subagentActivity.get(data.agentId) || []; const activity = this.subagentActivity.get(data.agentId) || [];
activity.push({ type: 'progress', ...data }); activity.push({ type: 'progress', ...data });
if (activity.length > 100) activity.shift(); if (activity.length > 50) activity.shift();
this.subagentActivity.set(data.agentId, activity); this.subagentActivity.set(data.agentId, activity);
if (this.activeSubagentId === data.agentId) { if (this.activeSubagentId === data.agentId) {
this.renderSubagentDetail(); this.renderSubagentDetail();
@@ -3045,7 +3079,7 @@ class ClaudemanApp {
const data = JSON.parse(e.data); const data = JSON.parse(e.data);
const activity = this.subagentActivity.get(data.agentId) || []; const activity = this.subagentActivity.get(data.agentId) || [];
activity.push({ type: 'message', ...data }); activity.push({ type: 'message', ...data });
if (activity.length > 100) activity.shift(); if (activity.length > 50) activity.shift();
this.subagentActivity.set(data.agentId, activity); this.subagentActivity.set(data.agentId, activity);
if (this.activeSubagentId === data.agentId) { if (this.activeSubagentId === data.agentId) {
this.renderSubagentDetail(); this.renderSubagentDetail();
@@ -3058,16 +3092,21 @@ class ClaudemanApp {
addListener('subagent:tool_result', (e) => { addListener('subagent:tool_result', (e) => {
const data = JSON.parse(e.data); 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)) { if (!this.subagentToolResults.has(data.agentId)) {
this.subagentToolResults.set(data.agentId, new Map()); 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 // Add to activity stream
const activity = this.subagentActivity.get(data.agentId) || []; const activity = this.subagentActivity.get(data.agentId) || [];
activity.push({ type: 'tool_result', ...data }); activity.push({ type: 'tool_result', ...data });
if (activity.length > 100) activity.shift(); if (activity.length > 50) activity.shift();
this.subagentActivity.set(data.agentId, activity); this.subagentActivity.set(data.agentId, activity);
if (this.activeSubagentId === data.agentId) { if (this.activeSubagentId === data.agentId) {
@@ -3373,6 +3412,12 @@ class ClaudemanApp {
this.writeFrameScheduled = false; this.writeFrameScheduled = false;
// Clear pending hooks // Clear pending hooks
this.pendingHooks.clear(); 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 // Clear tab alerts
this.tabAlerts.clear(); this.tabAlerts.clear();
// Clear shown completions (used for duplicate notification prevention) // Clear shown completions (used for duplicate notification prevention)
@@ -3674,14 +3719,14 @@ class ClaudemanApp {
const minimizedCount = minimizedAgents?.size || 0; const minimizedCount = minimizedAgents?.size || 0;
const subagentBadge = minimizedCount > 0 ? this.renderSubagentTabBadge(id, minimizedAgents) : ''; const subagentBadge = minimizedCount > 0 ? this.renderSubagentTabBadge(id, minimizedAgents) : '';
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}" data-id="${id}" data-color="${color}" onclick="app.selectSession('${id}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${id}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${this.escapeHtml(name)} session"> parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}" data-id="${id}" data-color="${color}" onclick="app.selectSession('${this.escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${this.escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${this.escapeHtml(name)} session">
<span class="tab-status ${status}" aria-hidden="true"></span> <span class="tab-status ${status}" aria-hidden="true"></span>
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : ''} ${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : ''}
<span class="tab-name" data-session-id="${id}">${this.escapeHtml(name)}</span> <span class="tab-name" data-session-id="${id}">${this.escapeHtml(name)}</span>
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''} ${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''}
${subagentBadge} ${subagentBadge}
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${id}')" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span> <span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${this.escapeHtml(id)}')" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span>
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${id}')" title="Close session" aria-label="Close session" tabindex="0">&times;</span> <span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${this.escapeHtml(id)}')" title="Close session" aria-label="Close session" tabindex="0">&times;</span>
</div>`); </div>`);
} }
@@ -3756,10 +3801,10 @@ class ClaudemanApp {
const truncatedName = displayName.length > 25 ? displayName.substring(0, 25) + '…' : displayName; const truncatedName = displayName.length > 25 ? displayName.substring(0, 25) + '…' : displayName;
const statusClass = agent?.status || 'idle'; const statusClass = agent?.status || 'idle';
agentItems.push(` agentItems.push(`
<div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreMinimizedSubagent('${agentId}', '${sessionId}')" title="Click to restore"> <div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreMinimizedSubagent('${this.escapeHtml(agentId)}', '${this.escapeHtml(sessionId)}')" title="Click to restore">
<span class="subagent-dropdown-status ${statusClass}"></span> <span class="subagent-dropdown-status ${statusClass}"></span>
<span class="subagent-dropdown-name">${this.escapeHtml(truncatedName)}</span> <span class="subagent-dropdown-name">${this.escapeHtml(truncatedName)}</span>
<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.permanentlyCloseMinimizedSubagent('${agentId}', '${sessionId}')" title="Dismiss">&times;</span> <span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.permanentlyCloseMinimizedSubagent('${this.escapeHtml(agentId)}', '${this.escapeHtml(sessionId)}')" title="Dismiss">&times;</span>
</div> </div>
`); `);
} }
@@ -6680,7 +6725,7 @@ class ClaudemanApp {
}); });
} catch (err) { } catch (err) {
body.innerHTML = `<div class="plan-fm-error">Error: ${err.message}</div>`; body.innerHTML = `<div class="plan-fm-error">Error: ${this.escapeHtml(err.message)}</div>`;
} }
} }
@@ -6767,7 +6812,7 @@ class ClaudemanApp {
win.innerHTML = ` win.innerHTML = `
<div class="plan-file-window-header"> <div class="plan-file-window-header">
<span class="plan-file-window-title">${typeLabels[agentType] || agentType} / ${this.escapeHtml(fileName)}</span> <span class="plan-file-window-title">${typeLabels[agentType] || agentType} / ${this.escapeHtml(fileName)}</span>
<button class="plan-file-window-close" onclick="app.closePlanFileWindow('${windowId}')">&times;</button> <button class="plan-file-window-close" onclick="app.closePlanFileWindow('${this.escapeHtml(windowId)}')">&times;</button>
</div> </div>
<div class="plan-file-window-body"> <div class="plan-file-window-body">
<pre class="plan-file-content ${isJson ? 'json' : 'markdown'}">${this.escapeHtml(content)}</pre> <pre class="plan-file-content ${isJson ? 'json' : 'markdown'}">${this.escapeHtml(content)}</pre>
@@ -8175,23 +8220,18 @@ class ClaudemanApp {
// Send Ctrl+L to fix display for newly created sessions once Claude is running // Send Ctrl+L to fix display for newly created sessions once Claude is running
sendPendingCtrlL(sessionId) { sendPendingCtrlL(sessionId) {
console.log('[DEBUG] sendPendingCtrlL called for:', sessionId, 'pending:', this.pendingCtrlL ? [...this.pendingCtrlL] : 'none');
if (!this.pendingCtrlL || !this.pendingCtrlL.has(sessionId)) { if (!this.pendingCtrlL || !this.pendingCtrlL.has(sessionId)) {
console.log('[DEBUG] No pending Ctrl+L for this session');
return; return;
} }
this.pendingCtrlL.delete(sessionId); this.pendingCtrlL.delete(sessionId);
// Only send if this is the active session // Only send if this is the active session
if (sessionId !== this.activeSessionId) { if (sessionId !== this.activeSessionId) {
console.log('[DEBUG] Not active session, skipping Ctrl+L');
return; return;
} }
console.log('[DEBUG] Sending resize + Ctrl+L for session:', sessionId);
// Send resize + Ctrl+L to fix the display (with minimum dimension enforcement) // Send resize + Ctrl+L to fix the display (with minimum dimension enforcement)
this.sendResize(sessionId).then(() => { this.sendResize(sessionId).then(() => {
console.log('[DEBUG] Resize sent, now sending Ctrl+L');
fetch(`/api/sessions/${sessionId}/input`, { fetch(`/api/sessions/${sessionId}/input`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -9071,7 +9111,7 @@ class ClaudemanApp {
const data = await response.json(); const data = await response.json();
if (!data.success) { if (!data.success) {
timeline.innerHTML = `<p class="empty-message">Failed to load summary: ${data.error}</p>`; timeline.innerHTML = `<p class="empty-message">Failed to load summary: ${this.escapeHtml(data.error)}</p>`;
return; return;
} }
@@ -10008,7 +10048,7 @@ class ClaudemanApp {
if (!agent) continue; // Agent no longer exists if (!agent) continue; // Agent no longer exists
// Skip completed or old agents // Skip completed or old agents
const agentStartTime = agent.startedAt ? new Date(agent.startedAt).getTime() : 0; const agentStartTime = agent.startedAt || 0;
if (agent.status === 'completed' || agentStartTime < cutoffTime) continue; if (agent.status === 'completed' || agentStartTime < cutoffTime) continue;
// Use the PERSISTENT parent map (THE source of truth) // Use the PERSISTENT parent map (THE source of truth)
@@ -10038,7 +10078,7 @@ class ClaudemanApp {
for (const { agentId, position } of (states.open || [])) { for (const { agentId, position } of (states.open || [])) {
const agent = this.subagents.get(agentId); const agent = this.subagents.get(agentId);
// Only restore window if agent exists, is recent, and is still active/idle // Only restore window if agent exists, is recent, and is still active/idle
const agentAge = agent?.startedAt ? now - new Date(agent.startedAt).getTime() : Infinity; const agentAge = agent?.startedAt ? now - agent.startedAt : Infinity;
if (agent && agent.status !== 'completed' && agentAge < maxAgeMs) { if (agent && agent.status !== 'completed' && agentAge < maxAgeMs) {
this.openSubagentWindow(agentId); this.openSubagentWindow(agentId);
// Restore position if saved (with viewport bounds check) // Restore position if saved (with viewport bounds check)
@@ -10132,11 +10172,10 @@ class ClaudemanApp {
} }
} }
// Populate the map // Populate the map (prune stale entries: require both session and agent to exist)
if (mapData && typeof mapData === 'object') { if (mapData && typeof mapData === 'object') {
for (const [agentId, sessionId] of Object.entries(mapData)) { for (const [agentId, sessionId] of Object.entries(mapData)) {
// Only keep associations for sessions that still exist if (this.sessions.has(sessionId) && this.subagents.has(agentId)) {
if (this.sessions.has(sessionId)) {
this.subagentParentMap.set(agentId, sessionId); this.subagentParentMap.set(agentId, sessionId);
} }
} }
@@ -11688,7 +11727,7 @@ class ClaudemanApp {
// Active first, then by last activity // Active first, then by last activity
if (a.status === 'active' && b.status !== 'active') return -1; if (a.status === 'active' && b.status !== 'active') return -1;
if (b.status === 'active' && a.status !== 'active') return 1; if (b.status === 'active' && a.status !== 'active') return 1;
return new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime(); return (b.lastActivityAt || 0) - (a.lastActivityAt || 0);
}); });
for (const agent of sorted) { for (const agent of sorted) {
@@ -11709,8 +11748,8 @@ class ClaudemanApp {
const agentIcon = teammateInfo ? `<span class="subagent-icon teammate-dot teammate-color-${teammateInfo.color}">●</span>` : '<span class="subagent-icon">🤖</span>'; const agentIcon = teammateInfo ? `<span class="subagent-icon teammate-dot teammate-color-${teammateInfo.color}">●</span>` : '<span class="subagent-icon">🤖</span>';
html.push(` html.push(`
<div class="subagent-item ${statusClass} ${isActive ? 'selected' : ''}${teammateInfo ? ' is-teammate' : ''}" <div class="subagent-item ${statusClass} ${isActive ? 'selected' : ''}${teammateInfo ? ' is-teammate' : ''}"
onclick="app.selectSubagent('${agent.agentId}')" onclick="app.selectSubagent('${this.escapeHtml(agent.agentId)}')"
ondblclick="app.openSubagentWindow('${agent.agentId}')" ondblclick="app.openSubagentWindow('${this.escapeHtml(agent.agentId)}')"
title="Double-click to open tracking window"> title="Double-click to open tracking window">
<div class="subagent-header"> <div class="subagent-header">
${agentIcon} ${agentIcon}
@@ -11718,8 +11757,8 @@ class ClaudemanApp {
${teammateBadge} ${teammateBadge}
${modelBadge} ${modelBadge}
<span class="subagent-status ${statusClass}">${agent.status}</span> <span class="subagent-status ${statusClass}">${agent.status}</span>
${canKill ? `<button class="subagent-kill-btn" onclick="event.stopPropagation(); app.killSubagent('${agent.agentId}')" title="Kill agent">&#x2715;</button>` : ''} ${canKill ? `<button class="subagent-kill-btn" onclick="event.stopPropagation(); app.killSubagent('${this.escapeHtml(agent.agentId)}')" title="Kill agent">&#x2715;</button>` : ''}
<button class="subagent-window-btn" onclick="event.stopPropagation(); app.${hasWindow ? 'closeSubagentWindow' : 'openSubagentWindow'}('${agent.agentId}')" title="${hasWindow ? 'Close window' : 'Open in window'}"> <button class="subagent-window-btn" onclick="event.stopPropagation(); app.${hasWindow ? 'closeSubagentWindow' : 'openSubagentWindow'}('${this.escapeHtml(agent.agentId)}')" title="${hasWindow ? 'Close window' : 'Open in window'}">
${hasWindow ? '✕' : '⧉'} ${hasWindow ? '✕' : '⧉'}
</button> </button>
</div> </div>
@@ -11766,7 +11805,7 @@ class ClaudemanApp {
<span class="icon">${this.getToolIcon(a.tool)}</span> <span class="icon">${this.getToolIcon(a.tool)}</span>
<span class="name">${a.tool}</span> <span class="name">${a.tool}</span>
<span class="detail">${toolDetail.primary}</span> <span class="detail">${toolDetail.primary}</span>
${toolDetail.hasMore ? `<button class="tool-expand-btn" onclick="app.toggleToolParams('${a.toolUseId}')">▶</button>` : ''} ${toolDetail.hasMore ? `<button class="tool-expand-btn" onclick="app.toggleToolParams('${this.escapeHtml(a.toolUseId)}')">▶</button>` : ''}
${toolDetail.hasMore ? `<div class="tool-params-expanded" id="tool-params-${a.toolUseId}" style="display:none;"><pre>${this.escapeHtml(JSON.stringify(a.fullInput || a.input, null, 2))}</pre></div>` : ''} ${toolDetail.hasMore ? `<div class="tool-params-expanded" id="tool-params-${a.toolUseId}" style="display:none;"><pre>${this.escapeHtml(JSON.stringify(a.fullInput || a.input, null, 2))}</pre></div>` : ''}
</div>`; </div>`;
} else if (a.type === 'tool_result') { } else if (a.type === 'tool_result') {
@@ -11815,7 +11854,7 @@ class ClaudemanApp {
<span class="subagent-id" title="${this.escapeHtml(agent.description || agent.agentId)}">${this.escapeHtml(detailTitle.length > 60 ? detailTitle.substring(0, 60) + '...' : detailTitle)}</span> <span class="subagent-id" title="${this.escapeHtml(agent.description || agent.agentId)}">${this.escapeHtml(detailTitle.length > 60 ? detailTitle.substring(0, 60) + '...' : detailTitle)}</span>
${modelBadge} ${modelBadge}
<span class="subagent-status ${agent.status}">${agent.status}</span> <span class="subagent-status ${agent.status}">${agent.status}</span>
<button class="subagent-transcript-btn" onclick="app.viewSubagentTranscript('${agent.agentId}')"> <button class="subagent-transcript-btn" onclick="app.viewSubagentTranscript('${this.escapeHtml(agent.agentId)}')">
View Full Transcript View Full Transcript
</button> </button>
</div> </div>
@@ -12148,7 +12187,7 @@ class ClaudemanApp {
parentDiv.dataset.parentSession = parentSessionId; parentDiv.dataset.parentSession = parentSessionId;
parentDiv.innerHTML = ` parentDiv.innerHTML = `
<span class="parent-label">from</span> <span class="parent-label">from</span>
<span class="parent-name" onclick="app.selectSession('${parentSessionId}')">${this.escapeHtml(parentName)}</span> <span class="parent-name" onclick="app.selectSession('${this.escapeHtml(parentSessionId)}')">${this.escapeHtml(parentName)}</span>
`; `;
header.insertAdjacentElement('afterend', parentDiv); header.insertAdjacentElement('afterend', parentDiv);
} }
@@ -12397,6 +12436,8 @@ class ClaudemanApp {
} }
// Update connection lines after visibility changes // Update connection lines after visibility changes
this.updateConnectionLines(); this.updateConnectionLines();
// Restack mobile windows after visibility changes
this.relayoutMobileSubagentWindows();
} }
// ========== Subagent Floating Windows ========== // ========== Subagent Floating Windows ==========
@@ -12453,16 +12494,24 @@ class ClaudemanApp {
let finalY = 0; let finalY = 0;
if (isMobile) { if (isMobile) {
// Mobile: stack compact cards above toolbar. Count visible (non-minimized) windows. // Mobile: stack compact cards. Count visible (non-minimized) windows.
let visibleCount = 0; let visibleCount = 0;
for (const [, data] of this.subagentWindows) { for (const [, data] of this.subagentWindows) {
if (!data.minimized && !data.hidden) visibleCount++; if (!data.minimized && !data.hidden) visibleCount++;
} }
const toolbarHeight = 40;
// Stack from bottom: first card just above toolbar, next above that, etc.
const bottomOffset = toolbarHeight + visibleCount * (mobileCardHeight + mobileCardGap);
finalX = 4; finalX = 4;
finalY = viewportHeight - bottomOffset - mobileCardHeight; const keyboardUp = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible;
if (keyboardUp) {
// Keyboard visible: stack from bottom above toolbar
const toolbarHeight = 40;
const bottomOffset = toolbarHeight + visibleCount * (mobileCardHeight + mobileCardGap);
finalY = viewportHeight - bottomOffset - mobileCardHeight;
} else {
// Keyboard hidden: stack from top below header with spacing
const headerHeight = document.querySelector('.header')?.offsetHeight || 36;
const topStart = headerHeight + 8;
finalY = topStart + visibleCount * (mobileCardHeight + mobileCardGap);
}
} else { } else {
// Check if Ralph wizard modal is open - if so, position windows on the sides // Check if Ralph wizard modal is open - if so, position windows on the sides
const wizardModal = document.getElementById('ralphWizardModal'); const wizardModal = document.getElementById('ralphWizardModal');
@@ -12545,7 +12594,7 @@ class ClaudemanApp {
const parentHeader = parentSessionId && parentSessionName const parentHeader = parentSessionId && parentSessionName
? `<div class="subagent-window-parent" data-parent-session="${parentSessionId}"> ? `<div class="subagent-window-parent" data-parent-session="${parentSessionId}">
<span class="parent-label">from</span> <span class="parent-label">from</span>
<span class="parent-name" onclick="app.selectSession('${parentSessionId}')">${this.escapeHtml(parentSessionName)}</span> <span class="parent-name" onclick="app.selectSession('${this.escapeHtml(parentSessionId)}')">${this.escapeHtml(parentSessionName)}</span>
</div>` </div>`
: ''; : '';
@@ -12565,7 +12614,7 @@ class ClaudemanApp {
<span class="status ${agent.status}">${agent.status}</span> <span class="status ${agent.status}">${agent.status}</span>
</div> </div>
<div class="subagent-window-actions"> <div class="subagent-window-actions">
<button onclick="app.closeSubagentWindow('${agentId}')" title="Minimize to tab">─</button> <button onclick="app.closeSubagentWindow('${this.escapeHtml(agentId)}')" title="Minimize to tab">─</button>
</div> </div>
</div> </div>
${parentHeader} ${parentHeader}
@@ -12576,14 +12625,9 @@ class ClaudemanApp {
// If we have a parent tab, start window at tab position for spawn animation // If we have a parent tab, start window at tab position for spawn animation
if (isMobile) { if (isMobile) {
// Mobile: position as stacked card via inline bottom offset // Mobile: position using top (keyboard-aware positioning calculated above)
let visibleCount = 0; win.style.top = `${finalY}px`;
for (const [, data] of this.subagentWindows) { win.style.bottom = 'auto';
if (!data.minimized && !data.hidden) visibleCount++;
}
const bottomPx = 40 + visibleCount * (mobileCardHeight + mobileCardGap);
win.style.bottom = `${bottomPx}px`;
win.style.top = 'auto';
} else if (parentTab) { } else if (parentTab) {
const tabRect = parentTab.getBoundingClientRect(); const tabRect = parentTab.getBoundingClientRect();
win.style.left = `${tabRect.left}px`; win.style.left = `${tabRect.left}px`;
@@ -12709,6 +12753,34 @@ class ClaudemanApp {
// Persist the state change // Persist the state change
this.saveSubagentWindowStates(); this.saveSubagentWindowStates();
this.updateConnectionLines(); 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) // Close all subagent windows for a session (fully removes them, not minimize)
@@ -12951,6 +13023,8 @@ class ClaudemanApp {
} }
windowData.minimized = false; windowData.minimized = false;
this.updateConnectionLines(); this.updateConnectionLines();
// Restack all visible mobile windows so restored ones don't overlap
this.relayoutMobileSubagentWindows();
} }
} }
@@ -13385,7 +13459,7 @@ class ClaudemanApp {
<span class="status running">terminal</span> <span class="status running">terminal</span>
</div> </div>
<div class="subagent-window-actions"> <div class="subagent-window-actions">
<button onclick="app.closeSubagentWindow('${windowId}')" title="Minimize to tab">─</button> <button onclick="app.closeSubagentWindow('${this.escapeHtml(windowId)}')" title="Minimize to tab">─</button>
</div> </div>
</div> </div>
<div class="subagent-window-body teammate-terminal-body" id="subagent-window-body-${windowId}"> <div class="subagent-window-body teammate-terminal-body" id="subagent-window-body-${windowId}">
@@ -13881,7 +13955,7 @@ class ClaudemanApp {
const fileName = path.split('/').pop(); const fileName = path.split('/').pop();
html.push(` html.push(`
<span class="project-insight-filepath" <span class="project-insight-filepath"
onclick="app.openLogViewerWindow('${this.escapeHtml(path)}', '${tool.sessionId}')" onclick="app.openLogViewerWindow('${this.escapeHtml(path)}', '${this.escapeHtml(tool.sessionId)}')"
title="${this.escapeHtml(path)}">${this.escapeHtml(fileName)}</span> title="${this.escapeHtml(path)}">${this.escapeHtml(fileName)}</span>
`); `);
} }
@@ -13939,7 +14013,7 @@ class ClaudemanApp {
} }
} catch (err) { } catch (err) {
console.error('Failed to load file browser:', err); console.error('Failed to load file browser:', err);
treeEl.innerHTML = `<div class="file-browser-empty">Failed to load files: ${err.message}</div>`; treeEl.innerHTML = `<div class="file-browser-empty">Failed to load files: ${this.escapeHtml(err.message)}</div>`;
} }
} }
@@ -14143,7 +14217,7 @@ class ClaudemanApp {
} }
} catch (err) { } catch (err) {
console.error('Failed to preview file:', err); console.error('Failed to preview file:', err);
bodyEl.innerHTML = `<div class="binary-message">Error: ${err.message}</div>`; bodyEl.innerHTML = `<div class="binary-message">Error: ${this.escapeHtml(err.message)}</div>`;
} }
} }
@@ -14250,7 +14324,7 @@ class ClaudemanApp {
<span class="status streaming">streaming</span> <span class="status streaming">streaming</span>
</div> </div>
<div class="log-viewer-window-actions"> <div class="log-viewer-window-actions">
<button onclick="app.closeLogViewerWindow('${windowId}')" title="Close">×</button> <button onclick="app.closeLogViewerWindow('${this.escapeHtml(windowId)}')" title="Close">×</button>
</div> </div>
</div> </div>
<div class="log-viewer-window-body" id="log-viewer-body-${windowId}"> <div class="log-viewer-window-body" id="log-viewer-body-${windowId}">
@@ -14419,14 +14493,14 @@ class ClaudemanApp {
<span class="size-badge">${sizeKB} KB</span> <span class="size-badge">${sizeKB} KB</span>
</div> </div>
<div class="image-popup-actions"> <div class="image-popup-actions">
<button onclick="app.openImageInNewTab('${imageUrl}')" title="Open in new tab">↗</button> <button onclick="app.openImageInNewTab('${this.escapeHtml(imageUrl)}')" title="Open in new tab">↗</button>
<button onclick="app.closeImagePopup('${imageId}')" title="Close">×</button> <button onclick="app.closeImagePopup('${this.escapeHtml(imageId)}')" title="Close">×</button>
</div> </div>
</div> </div>
<div class="image-popup-body"> <div class="image-popup-body">
<img src="${imageUrl}" alt="${this.escapeHtml(fileName)}" <img src="${imageUrl}" alt="${this.escapeHtml(fileName)}"
onerror="this.parentElement.innerHTML='<div class=\\'image-error\\'>Failed to load image</div>'" onerror="this.parentElement.innerHTML='<div class=\\'image-error\\'>Failed to load image</div>'"
onclick="app.openImageInNewTab('${imageUrl}')" /> onclick="app.openImageInNewTab('${this.escapeHtml(imageUrl)}')" />
</div> </div>
`; `;
@@ -14923,7 +14997,7 @@ class ClaudemanApp {
</div> </div>
</div> </div>
<div class="process-actions"> <div class="process-actions">
<button class="btn-toolbar btn-sm btn-danger" onclick="app.killScreen('${screen.sessionId}')" title="Kill tmux session">Kill</button> <button class="btn-toolbar btn-sm btn-danger" onclick="app.killScreen('${this.escapeHtml(screen.sessionId)}')" title="Kill tmux session">Kill</button>
</div> </div>
</div> </div>
`; `;
@@ -14966,7 +15040,7 @@ class ClaudemanApp {
</div> </div>
</div> </div>
<div class="process-actions"> <div class="process-actions">
${agent.status !== 'completed' ? `<button class="btn-toolbar btn-sm btn-danger" onclick="app.killSubagent('${agent.agentId}')" title="Kill agent">Kill</button>` : ''} ${agent.status !== 'completed' ? `<button class="btn-toolbar btn-sm btn-danger" onclick="app.killSubagent('${this.escapeHtml(agent.agentId)}')" title="Kill agent">Kill</button>` : ''}
</div> </div>
</div> </div>
`; `;
+41 -12
View File
@@ -94,23 +94,28 @@ export const RespawnConfigSchema = z.object({
interStepDelayMs: z.number().int().min(100).max(60000).optional(), interStepDelayMs: z.number().int().min(100).max(60000).optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
sendClear: z.boolean().optional(), sendClear: z.boolean().optional(),
sendUpdate: z.boolean().optional(),
sendInit: z.boolean().optional(), sendInit: z.boolean().optional(),
sendKickstart: z.boolean().optional(),
kickstartPrompt: z.string().max(10000).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(), completionConfirmMs: z.number().int().min(1000).max(60000).optional(),
noOutputTimeoutMs: z.number().int().min(5000).max(600000).optional(), noOutputTimeoutMs: z.number().int().min(5000).max(600000).optional(),
maxIterations: z.number().int().min(0).max(10000).optional(), autoAcceptPrompts: z.boolean().optional(),
stuckStateWarningMs: z.number().int().min(60000).max(3600000).optional(),
autoAcceptEnabled: z.boolean().optional(),
autoAcceptDelayMs: z.number().int().min(1000).max(60000).optional(), autoAcceptDelayMs: z.number().int().min(1000).max(60000).optional(),
planModeEnabled: z.boolean().optional(), aiIdleCheckEnabled: z.boolean().optional(),
planCheckTimeoutMs: z.number().int().min(10000).max(300000).optional(), aiIdleCheckModel: z.string().max(100).optional(),
planCheckModel: z.string().max(100).optional(), aiIdleCheckMaxContext: z.number().int().min(1000).max(500000).optional(),
}).strict(); 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 * Schema for PUT /api/config
@@ -253,3 +258,27 @@ export const CpuLimitSchema = z.object({
ioClass: z.enum(['idle', 'best-effort', 'realtime']).optional(), ioClass: z.enum(['idle', 'best-effort', 'realtime']).optional(),
ioLevel: z.number().int().min(0).max(7).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(),
});
+178 -118
View File
@@ -56,7 +56,7 @@ import {
import { import {
CreateSessionSchema, CreateSessionSchema,
RunPromptSchema, RunPromptSchema,
SessionInputSchema, SessionInputWithLimitSchema,
ResizeSchema, ResizeSchema,
CreateCaseSchema, CreateCaseSchema,
QuickStartSchema, QuickStartSchema,
@@ -81,6 +81,12 @@ import {
PlanTaskUpdateSchema, PlanTaskUpdateSchema,
PlanTaskAddSchema, PlanTaskAddSchema,
CpuLimitSchema, CpuLimitSchema,
SettingsUpdateSchema,
ModelConfigUpdateSchema,
SubagentWindowStatesSchema,
SubagentParentMapSchema,
InteractiveRespawnSchema,
RespawnEnableSchema,
} from './schemas.js'; } from './schemas.js';
import { StaleExpirationMap } from '../utils/index.js'; import { StaleExpirationMap } from '../utils/index.js';
@@ -349,7 +355,7 @@ export class WebServer extends EventEmitter {
private testMode: boolean; private testMode: boolean;
private mux: TerminalMultiplexer; private mux: TerminalMultiplexer;
// Terminal batching for performance // Terminal batching for performance
private terminalBatches: Map<string, string> = new Map(); private terminalBatches: Map<string, string[]> = new Map();
private terminalBatchTimer: NodeJS.Timeout | null = null; private terminalBatchTimer: NodeJS.Timeout | null = null;
// Adaptive batching: track rapid events to extend batch window (per-session) // Adaptive batching: track rapid events to extend batch window (per-session)
// StaleExpirationMap auto-cleans entries for sessions that stop generating output // StaleExpirationMap auto-cleans entries for sessions that stop generating output
@@ -536,6 +542,16 @@ export class WebServer extends EventEmitter {
threshold: 1024, 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 // Serve static files with caching headers for immutable CDN-like assets
await this.app.register(fastifyStatic, { await this.app.register(fastifyStatic, {
root: join(__dirname, 'public'), root: join(__dirname, 'public'),
@@ -712,7 +728,7 @@ export class WebServer extends EventEmitter {
// Write env overrides to .claude/settings.local.json if provided // Write env overrides to .claude/settings.local.json if provided
if (body.envOverrides && Object.keys(body.envOverrides).length > 0) { if (body.envOverrides && Object.keys(body.envOverrides).length > 0) {
updateCaseEnvVars(workingDir, body.envOverrides); await updateCaseEnvVars(workingDir, body.envOverrides);
} }
const globalNice = await this.getGlobalNiceConfig(); const globalNice = await this.getGlobalNiceConfig();
@@ -738,7 +754,11 @@ export class WebServer extends EventEmitter {
// Rename a session // Rename a session
this.app.put('/api/sessions/:id/name', async (req) => { this.app.put('/api/sessions/:id/name', async (req) => {
const { id } = req.params as { id: string }; 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); const session = this.sessions.get(id);
if (!session) { if (!session) {
@@ -757,7 +777,11 @@ export class WebServer extends EventEmitter {
// Set session color // Set session color
this.app.put('/api/sessions/:id/color', async (req) => { this.app.put('/api/sessions/:id/color', async (req) => {
const { id } = req.params as { id: string }; 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); const session = this.sessions.get(id);
if (!session) { if (!session) {
@@ -1216,12 +1240,16 @@ export class WebServer extends EventEmitter {
// Configure Ralph (Ralph Wiggum) settings // Configure Ralph (Ralph Wiggum) settings
this.app.post('/api/sessions/:id/ralph-config', async (req) => { this.app.post('/api/sessions/:id/ralph-config', async (req) => {
const { id } = req.params as { id: string }; 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; enabled?: boolean;
completionPhrase?: string; completionPhrase?: string;
maxIterations?: number; maxIterations?: number;
reset?: boolean | 'full'; // true = soft reset (keep enabled), 'full' = complete reset reset?: boolean | 'full';
disableAutoEnable?: boolean; // Prevent auto-enable on pattern detection disableAutoEnable?: boolean;
}; };
const session = this.sessions.get(id); const session = this.sessions.get(id);
@@ -1339,17 +1367,17 @@ export class WebServer extends EventEmitter {
// Import todos from @fix_plan.md content // Import todos from @fix_plan.md content
this.app.post('/api/sessions/:id/fix-plan/import', async (req) => { this.app.post('/api/sessions/:id/fix-plan/import', async (req) => {
const { id } = req.params as { id: string }; 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); const session = this.sessions.get(id);
if (!session) { if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); 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); const importedCount = session.ralphTracker.importFixPlanMarkdown(content);
this.persistSessionState(session); this.persistSessionState(session);
@@ -1434,7 +1462,11 @@ export class WebServer extends EventEmitter {
// This avoids screen input escaping issues with long multi-line prompts // This avoids screen input escaping issues with long multi-line prompts
this.app.post('/api/sessions/:id/ralph-prompt/write', async (req) => { this.app.post('/api/sessions/:id/ralph-prompt/write', async (req) => {
const { id } = req.params as { id: string }; 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); const session = this.sessions.get(id);
if (!session) { if (!session) {
@@ -1446,10 +1478,6 @@ export class WebServer extends EventEmitter {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Session has no working directory'); 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'); const filePath = path.join(workingDir, '@ralph_prompt.md');
try { try {
@@ -1552,7 +1580,7 @@ export class WebServer extends EventEmitter {
// useScreen: true uses writeViaScreen which is more reliable for programmatic input // useScreen: true uses writeViaScreen which is more reliable for programmatic input
this.app.post('/api/sessions/:id/input', async (req): Promise<ApiResponse> => { this.app.post('/api/sessions/:id/input', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const result = SessionInputSchema.safeParse(req.body); const result = SessionInputWithLimitSchema.safeParse(req.body);
if (!result.success) { if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); 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 // Start respawn controller for a session
this.app.post('/api/sessions/:id/respawn/start', async (req) => { this.app.post('/api/sessions/:id/respawn/start', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const body = req.body ? RespawnConfigSchema.parse(req.body) as Partial<RespawnConfig> : undefined; let body: Partial<RespawnConfig> | undefined;
if (req.body) {
const result = RespawnConfigSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid respawn config');
}
body = result.data as Partial<RespawnConfig>;
}
const session = this.sessions.get(id); const session = this.sessions.get(id);
if (!session) { if (!session) {
@@ -1823,9 +1858,11 @@ export class WebServer extends EventEmitter {
// Start interactive session WITH respawn enabled // Start interactive session WITH respawn enabled
this.app.post('/api/sessions/:id/interactive-respawn', async (req) => { this.app.post('/api/sessions/:id/interactive-respawn', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const body = req.body as { respawnConfig?: Partial<RespawnConfig>; durationMinutes?: number } | undefined; const irResult = req.body ? InteractiveRespawnSchema.safeParse(req.body) : { success: true as const, data: {} };
// Validate respawn config if present if (!irResult.success) {
if (body?.respawnConfig) RespawnConfigSchema.parse(body.respawnConfig); return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const body = irResult.data as { respawnConfig?: Partial<RespawnConfig>; durationMinutes?: number };
const session = this.sessions.get(id); const session = this.sessions.get(id);
if (!session) { if (!session) {
@@ -1881,9 +1918,11 @@ export class WebServer extends EventEmitter {
// Enable respawn on an EXISTING interactive session // Enable respawn on an EXISTING interactive session
this.app.post('/api/sessions/:id/respawn/enable', async (req) => { this.app.post('/api/sessions/:id/respawn/enable', async (req) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const body = req.body as { config?: Partial<RespawnConfig>; durationMinutes?: number } | undefined; const reResult = req.body ? RespawnEnableSchema.safeParse(req.body) : { success: true as const, data: {} };
// Validate respawn config if present if (!reResult.success) {
if (body?.config) RespawnConfigSchema.parse(body.config); return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const body = reResult.data as { config?: Partial<RespawnConfig>; durationMinutes?: number };
const session = this.sessions.get(id); const session = this.sessions.get(id);
if (!session) { if (!session) {
@@ -1930,21 +1969,17 @@ export class WebServer extends EventEmitter {
// Set auto-clear on a session // Set auto-clear on a session
this.app.post('/api/sessions/:id/auto-clear', async (req) => { this.app.post('/api/sessions/:id/auto-clear', async (req) => {
const { id } = req.params as { id: string }; 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); const session = this.sessions.get(id);
if (!session) { if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); 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); session.setAutoClear(body.enabled, body.threshold);
this.persistSessionState(session); this.persistSessionState(session);
this.broadcast('session:updated', this.getSessionStateWithRespawn(session)); this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
@@ -1963,21 +1998,17 @@ export class WebServer extends EventEmitter {
// Set auto-compact on a session // Set auto-compact on a session
this.app.post('/api/sessions/:id/auto-compact', async (req) => { this.app.post('/api/sessions/:id/auto-compact', async (req) => {
const { id } = req.params as { id: string }; 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); const session = this.sessions.get(id);
if (!session) { if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); 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); session.setAutoCompact(body.enabled, body.threshold, body.prompt);
this.persistSessionState(session); this.persistSessionState(session);
this.broadcast('session:updated', this.getSessionStateWithRespawn(session)); this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
@@ -1997,17 +2028,17 @@ export class WebServer extends EventEmitter {
// Toggle image watcher for a session // Toggle image watcher for a session
this.app.post('/api/sessions/:id/image-watcher', async (req) => { this.app.post('/api/sessions/:id/image-watcher', async (req) => {
const { id } = req.params as { id: string }; 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); const session = this.sessions.get(id);
if (!session) { if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
} }
if (body.enabled === undefined) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'enabled field is required');
}
if (body.enabled) { if (body.enabled) {
imageWatcher.watchSession(session.id, session.workingDir); imageWatcher.watchSession(session.id, session.workingDir);
} else { } else {
@@ -2029,17 +2060,17 @@ export class WebServer extends EventEmitter {
// Toggle flicker filter for a session // Toggle flicker filter for a session
this.app.post('/api/sessions/:id/flicker-filter', async (req) => { this.app.post('/api/sessions/:id/flicker-filter', async (req) => {
const { id } = req.params as { id: string }; 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); const session = this.sessions.get(id);
if (!session) { if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); 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; session.flickerFilterEnabled = body.enabled;
this.persistSessionState(session); this.persistSessionState(session);
this.broadcast('session:updated', this.getSessionStateWithRespawn(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`); 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()) { if (!prompt.trim()) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required'); return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required');
@@ -2091,8 +2126,12 @@ export class WebServer extends EventEmitter {
return Array.from(this.scheduledRuns.values()); return Array.from(this.scheduledRuns.values());
}); });
this.app.post('/api/scheduled', async (req): Promise<{ success: boolean; run: ScheduledRun }> => { this.app.post('/api/scheduled', async (req): Promise<{ success: boolean; run: ScheduledRun } | ApiResponse<never>> => {
const { prompt, workingDir, durationMinutes } = ScheduledRunSchema.parse(req.body); 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); const run = await this.startScheduledRun(prompt, workingDir || process.cwd(), durationMinutes ?? 60);
return { success: true, run }; return { success: true, run };
@@ -2128,8 +2167,8 @@ export class WebServer extends EventEmitter {
const cases: CaseInfo[] = []; const cases: CaseInfo[] = [];
// Get cases from casesDir // Get cases from casesDir
if (existsSync(casesDir)) { try {
const entries = readdirSync(casesDir, { withFileTypes: true }); const entries = await fs.readdir(casesDir, { withFileTypes: true });
for (const e of entries) { for (const e of entries) {
if (e.isDirectory()) { if (e.isDirectory()) {
cases.push({ cases.push({
@@ -2139,6 +2178,8 @@ export class WebServer extends EventEmitter {
}); });
} }
} }
} catch {
// casesDir may not exist yet
} }
// Get linked cases // Get linked cases
@@ -2195,7 +2236,7 @@ export class WebServer extends EventEmitter {
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd); writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
// Write .claude/settings.local.json with hooks for desktop notifications // Write .claude/settings.local.json with hooks for desktop notifications
writeHooksConfig(casePath); await writeHooksConfig(casePath);
this.broadcast('case:created', { name, path: casePath }); this.broadcast('case:created', { name, path: casePath });
@@ -2207,15 +2248,11 @@ export class WebServer extends EventEmitter {
// Link an existing folder as a case // Link an existing folder as a case
this.app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => { this.app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
const { name, path: folderPath } = LinkCaseSchema.parse(req.body); const lcResult = LinkCaseSchema.safeParse(req.body);
if (!lcResult.success) {
if (!name || !/^[a-zA-Z0-9_-]+$/.test(name)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
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 { name, path: folderPath } = lcResult.data;
// Expand ~ to home directory // Expand ~ to home directory
const expandedPath = folderPath.startsWith('~') const expandedPath = folderPath.startsWith('~')
@@ -2256,7 +2293,7 @@ export class WebServer extends EventEmitter {
if (!existsSync(claudemanDir)) { if (!existsSync(claudemanDir)) {
mkdirSync(claudemanDir, { recursive: true }); 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 }); this.broadcast('case:linked', { name, path: expandedPath });
return { success: true, data: { case: { name, path: expandedPath } } }; return { success: true, data: { case: { name, path: expandedPath } } };
} catch (err) { } catch (err) {
@@ -2408,12 +2445,12 @@ export class WebServer extends EventEmitter {
this.app.post('/api/quick-start', async (req): Promise<QuickStartResponse> => { this.app.post('/api/quick-start', async (req): Promise<QuickStartResponse> => {
// Prevent unbounded session creation // Prevent unbounded session creation
if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) { 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); const result = QuickStartSchema.safeParse(req.body);
if (!result.success) { 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; const { caseName = 'testcase', mode = 'claude' } = result.data;
@@ -2424,7 +2461,7 @@ export class WebServer extends EventEmitter {
const resolvedBase = resolve(casesDir); const resolvedBase = resolve(casesDir);
const relPath = relative(resolvedBase, resolvedPath); const relPath = relative(resolvedBase, resolvedPath);
if (relPath.startsWith('..') || isAbsolute(relPath)) { 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 // 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); writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
// Write .claude/settings.local.json with hooks for desktop notifications // Write .claude/settings.local.json with hooks for desktop notifications
writeHooksConfig(casePath); await writeHooksConfig(casePath);
this.broadcast('case:created', { name: caseName, path: casePath }); this.broadcast('case:created', { name: caseName, path: casePath });
} catch (err) { } 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) { } catch (err) {
// Clean up session on error to prevent orphaned resources // Clean up session on error to prevent orphaned resources
await this.cleanupSession(session.id); 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; type PlanItem = import('../plan-orchestrator.js').PlanItem;
this.app.post('/api/generate-plan', async (req): Promise<ApiResponse> => { this.app.post('/api/generate-plan', async (req): Promise<ApiResponse> => {
const gpResult = GeneratePlanSchema.safeParse(req.body);
if (!gpResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const { const {
taskDescription, taskDescription,
detailLevel = 'standard' detailLevel = 'standard'
} = GeneratePlanSchema.parse(req.body); } = gpResult.data;
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)');
}
// Build sophisticated prompt based on Ralph Wiggum methodology // Build sophisticated prompt based on Ralph Wiggum methodology
const detailConfig = { 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 // Generate detailed implementation plan using subagent orchestration
// This spawns multiple specialist subagents in parallel for thorough analysis // This spawns multiple specialist subagents in parallel for thorough analysis
this.app.post('/api/generate-plan-detailed', async (req): Promise<ApiResponse> => { this.app.post('/api/generate-plan-detailed', async (req): Promise<ApiResponse> => {
const { taskDescription, caseName } = GeneratePlanDetailedSchema.parse(req.body); const gpdResult = GeneratePlanDetailedSchema.safeParse(req.body);
if (!gpdResult.success) {
if (!taskDescription || typeof taskDescription !== 'string') { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
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 { taskDescription, caseName } = gpdResult.data;
// Determine output directory for saving wizard results // Determine output directory for saving wizard results
let outputDir: string | undefined; 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 // Cancel active plan generation
this.app.post('/api/cancel-plan-generation', async (req): Promise<ApiResponse> => { this.app.post('/api/cancel-plan-generation', async (req): Promise<ApiResponse> => {
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 specific orchestrator ID provided, cancel just that one
if (orchestratorId) { 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'); 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'; status?: 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked';
error?: string; error?: string;
incrementAttempts?: boolean; 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'); 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); const result = tracker.addPlanTask(task);
this.broadcast('session:planTaskAdded', { sessionId: id, task: result.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) => { this.app.put('/api/settings', async (req) => {
const settings = req.body as Record<string, unknown>; const settingsResult = SettingsUpdateSchema.safeParse(req.body);
if (!settingsResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid settings');
}
const settings = settingsResult.data as Record<string, unknown>;
try { try {
const dir = dirname(settingsPath); 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 }); mkdirSync(dir, { recursive: true });
} }
let existing: Record<string, unknown> = {}; let existing: Record<string, unknown> = {};
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 }; 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 // Handle subagent tracking toggle dynamically
const subagentEnabled = settings.subagentTrackingEnabled ?? true; 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) => { this.app.put('/api/execution/model-config', async (req) => {
const modelConfig = req.body as Record<string, unknown>; const mcResult = ModelConfigUpdateSchema.safeParse(req.body);
if (!mcResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid model config');
}
const modelConfig = mcResult.data as Record<string, unknown>;
try { try {
let settings: Record<string, unknown> = {}; let settings: Record<string, unknown> = {};
@@ -3162,7 +3211,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
if (!existsSync(dir)) { if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true }); mkdirSync(dir, { recursive: true });
} }
writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
return { success: true }; return { success: true };
} catch (err) { } 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'); return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
} }
const body = CpuLimitSchema.parse(req.body) as Partial<NiceConfig>; const clResult = CpuLimitSchema.safeParse(req.body);
if (!clResult.success) {
// Validate inputs return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
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 body = clResult.data as Partial<NiceConfig>;
session.setNice(body); session.setNice(body);
this.persistSessionState(session); 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) => { this.app.put('/api/subagent-window-states', async (req) => {
const states = req.body as Record<string, unknown>; const swResult = SubagentWindowStatesSchema.safeParse(req.body);
if (!swResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid window states');
}
const states = swResult.data as Record<string, unknown>;
try { try {
const dir = dirname(windowStatesPath); const dir = dirname(windowStatesPath);
if (!existsSync(dir)) { if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true }); mkdirSync(dir, { recursive: true });
} }
writeFileSync(windowStatesPath, JSON.stringify(states, null, 2)); await fs.writeFile(windowStatesPath, JSON.stringify(states, null, 2));
return { success: true }; return { success: true };
} catch (err) { } catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(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) => { this.app.put('/api/subagent-parents', async (req) => {
const parentMap = req.body as Record<string, string>; const spResult = SubagentParentMapSchema.safeParse(req.body);
if (!spResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid parent map');
}
const parentMap = spResult.data;
try { try {
const dir = dirname(parentMapPath); const dir = dirname(parentMapPath);
if (!existsSync(dir)) { if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true }); mkdirSync(dir, { recursive: true });
} }
writeFileSync(parentMapPath, JSON.stringify(parentMap, null, 2)); await fs.writeFile(parentMapPath, JSON.stringify(parentMap, null, 2));
return { success: true }; return { success: true };
} catch (err) { } catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(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 timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
const filename = `screenshot_${timestamp}${ext}`; const filename = `screenshot_${timestamp}${ext}`;
const filepath = join(SCREENSHOTS_DIR, filename); const filepath = join(SCREENSHOTS_DIR, filename);
writeFileSync(filepath, filePart.data); await fs.writeFile(filepath, filePart.data);
return { success: true, path: filepath, filename }; 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 // Skip if server is stopping
if (this._isStopping) return; if (this._isStopping) return;
const existing = this.terminalBatches.get(sessionId) || ''; let chunks = this.terminalBatches.get(sessionId);
const newBatch = existing + data; if (!chunks) {
this.terminalBatches.set(sessionId, newBatch); 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) // Adaptive batching: detect rapid events and extend batch window (per-session)
const now = Date.now(); 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 // Flush immediately if batch is large for responsiveness
if (newBatch.length > BATCH_FLUSH_THRESHOLD) { if (totalLength > BATCH_FLUSH_THRESHOLD) {
if (this.terminalBatchTimer) { if (this.terminalBatchTimer) {
clearTimeout(this.terminalBatchTimer); clearTimeout(this.terminalBatchTimer);
this.terminalBatchTimer = null; this.terminalBatchTimer = null;
@@ -4724,8 +4782,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
this.terminalBatches.clear(); this.terminalBatches.clear();
return; return;
} }
for (const [sessionId, data] of this.terminalBatches) { for (const [sessionId, chunks] of this.terminalBatches) {
if (data.length > 0) { 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 // Wrap with DEC mode 2026 synchronized output markers
// Terminal buffers all output between markers and renders atomically, // Terminal buffers all output between markers and renders atomically,
// eliminating partial-frame flicker from Ink's full-screen redraws. // eliminating partial-frame flicker from Ink's full-screen redraws.