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