mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
chore: bump version to 0.1528
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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">×${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">⚙</span>
|
||||
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${id}')" title="Close session" aria-label="Close session" tabindex="0">×</span>
|
||||
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${this.escapeHtml(id)}')" title="Session options" aria-label="Session options" tabindex="0">⚙</span>
|
||||
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${this.escapeHtml(id)}')" title="Close session" aria-label="Close session" tabindex="0">×</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">×</span>
|
||||
<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.permanentlyCloseMinimizedSubagent('${this.escapeHtml(agentId)}', '${this.escapeHtml(sessionId)}')" title="Dismiss">×</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}')">×</button>
|
||||
<button class="plan-file-window-close" onclick="app.closePlanFileWindow('${this.escapeHtml(windowId)}')">×</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">✕</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">✕</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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user