Compare commits

...
Author SHA1 Message Date
arkonandClaude Opus 4.6 d866c8f30e chore: version packages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 01:43:59 +01:00
arkonandClaude Opus 4.6 28537de39d refactor: pass 3 — extract helpers, split long functions, deduplicate patterns
Backend:
- subagent-watcher: split 176L processEntry() into 5 focused methods; extract
  _resolveDescription() deduplicating 3 call sites for description acquisition
- bash-tool-parser: split 152L processCleanLine() into 4 handlers; extract
  _createActiveTool() factory and _scheduleAutoRemove() helper
- session: extract _setupOrAttachMuxSession() deduplicating ~80L between
  startInteractive/startShell; extract _handleTerminalOutput()
- respawn-controller: split 180L handleTerminalData() into 3 detection layers;
  data-driven validation loop replacing 9 individual calls
- plan-orchestrator: extract _extractJsonFromResponse(), _emitAgentFailure(),
  _formatResearchSection() helpers
- orchestrator-loop: extract _finalizeTask() unifying task completion/failure;
  _clearTimer() utility for correct clearInterval/clearTimeout dispatch
- ralph-status-parser: config-driven FIELD_PARSERS[] replacing 8 near-identical
  field-matching blocks; split updateCircuitBreaker() into focused handlers
- state-store: extract _mergeWithInitialState() and _resetCircuitBreaker()

Frontend:
- app.js: add _notifySession() helper used by 18 call sites across 5 modules
- panels-ui.js: extract _addActivityEntry() replacing 4 duplicate blocks
- settings-ui.js: extract _updateTunnelUrlRow() deduplicating 2 blocks
- ralph-panel.js, respawn-ui.js: convert to _notifySession()

Routes:
- route-helpers: add toggleService() helper
- system-routes: use toggleService() for watcher toggles; extract collectActiveTokens()
- orchestrator-routes: data-driven EVENT_MAP replacing 10 identical listeners

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 22:50:25 +01:00
arkonandClaude Opus 4.6 ba09184efa fix: wizard "No JSON found" — Claude CLI stream-json returns empty result field
Claude CLI's --output-format stream-json now returns "result": "" in the result
message. The actual response text lives in assistant message text blocks, which
_textOutput correctly accumulates. runPrompt() was returning the empty
resultMsg.result without falling back to _textOutput.value.

Also improved plan-orchestrator JSON extraction to try code-block-wrapped JSON
first (```json {...} ```) before the greedy regex, plus debug logging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 23:53:57 +01:00
arkonandClaude Opus 4.6 93719b41cd chore: version packages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 23:34:14 +01:00
arkonandClaude Opus 4.6 a448983be3 refactor: pass 2 — extract shared helpers and simplify patterns
app.js:
- Add _clearTimer() helper replacing 11 inline clearTimeout patterns
- Add _isStaleSelect() helper for generation check + cleanup
- Replace 11 keyboard shortcut if-blocks with data-driven lookup table
- Extract _cleanupPreviousSession() from selectSession() (~75 lines)
- Extract _resetAllAppState() from handleInit() (~75 lines)

tmux-manager:
- Extract buildEnvExports() eliminating duplication in createSession/respawnPane
- Extract buildPathExport() for CLI path resolution
- Extract _configureOpenCode() for OpenCode setup

routes:
- Add readJsonConfig() to route-helpers, replacing 5 inline JSON-read patterns
- Add validateSessionFilePath() to route-helpers, replacing 2 identical path
  traversal validation blocks in file-routes

session-auto-ops:
- Convert executeWhenIdle() from 8 positional params to options object
- Extract validateThreshold() for shared compact/clear validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 23:32:28 +01:00
arkonandClaude Opus 4.6 3145eac6d9 refactor: extract helper methods to reduce duplication and improve readability
DRY up repeated patterns across 7 core files:
- state-store: extract serializeState() and split assembleStateJson() into 3 focused methods
- session: extract _resetBuffers(), _clearAllTimers(), _handleJsonMessage()
- ralph-tracker: extract completeAllTodos() (was 4x duplicated), emitValidationWarning(), similarity constants
- subagent-watcher: extract markSubagentAsCompleted(), extractFirstTextContent(), emitToolResult(), findOldestInactiveAgent()
- respawn-controller: extract recoveryResetToWatching(), canAutoAccept(), formatRemainingSeconds(), validatePositiveTimeout()
- tmux-manager: replace 15 path.includes() checks with single UNSAFE_PATH_CHARS regex
- session-auto-ops: extract executeWhenIdle() shared retry helper for checkAutoCompact/checkAutoClear

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 23:21:38 +01:00
arkonandClaude Opus 4.6 e3c609f5f0 test: add coverage for lastUsedCase partial update and strict schema rejection
Tests that partial PUT /api/settings with just lastUsedCase works correctly
and that including modelConfig triggers strict Zod schema rejection (the bug
fixed in #49).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 13:39:47 +01:00
Tenggan Zhang 52e774f83c fix: case selection not persisting across page refresh (#49)
Thank you for the clean fix! The root cause analysis in the PR description was excellent — the strict Zod schema rejecting modelConfig during the GET-then-PUT pattern was a subtle bug.
2026-03-25 13:39:16 +01:00
34 changed files with 3053 additions and 2817 deletions
+21
View File
@@ -1,5 +1,26 @@
# aicodeman
## 0.5.4
### Patch Changes
- Fix terminal flicker regression — re-add server-side DEC 2026 synchronized output wrapping around batched terminal data. Ink spinner frames (cursor-up + redraw cycles) do not emit their own DEC 2026 markers, so without the server wrapper each partial cursor update rendered individually causing visible flicker. Also: extract SSE stream management, session listener wiring, and respawn event wiring from server.ts into dedicated modules; deduplicate error message extraction across 7 files with shared getErrorMessage() helper; update SSE event count in CLAUDE.md (106 → 117).
## 0.5.3
### Patch Changes
- Readability refactor across 12 core files, extracting ~35 helper methods to reduce duplication:
- state-store: extract serializeState(), split assembleStateJson() into focused sub-methods
- session: extract \_resetBuffers() (3x dedup), \_clearAllTimers() (10 timer cleanups), \_handleJsonMessage()
- ralph-tracker: extract completeAllTodos() (4x dedup), emitValidationWarning(), named similarity constants
- subagent-watcher: extract markSubagentAsCompleted(), extractFirstTextContent(), emitToolResult(), findOldestInactiveAgent()
- respawn-controller: extract recoveryResetToWatching(), canAutoAccept(), formatRemainingSeconds(), validatePositiveTimeout()
- tmux-manager: replace 15 path.includes() with UNSAFE_PATH_CHARS regex, extract buildEnvExports/buildPathExport/\_configureOpenCode helpers
- session-auto-ops: extract executeWhenIdle() shared retry helper, convert to options object, add validateThreshold()
- app.js: add \_clearTimer() (11 call sites), \_isStaleSelect(), keyboard shortcut lookup table, \_cleanupPreviousSession(), \_resetAllAppState()
- route-helpers: add readJsonConfig() (5 inline patterns replaced), validateSessionFilePath() (2 duplicated blocks replaced)
## 0.5.2
### Patch Changes
+4 -4
View File
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
| Task | Command |
|------|---------|
| Dev server | `npx tsx src/index.ts web` |
| Dev server | `npm run dev` (or `npx tsx src/index.ts web`) |
| Type check | `tsc --noEmit` |
| Lint | `npm run lint` (fix: `npm run lint:fix`) |
| Format | `npm run format` (check: `npm run format:check`) |
@@ -52,7 +52,7 @@ When user says "COM":
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
**Version**: 0.5.2 (must match `package.json`)
**Version**: 0.5.4 (must match `package.json`)
## Project Overview
@@ -78,7 +78,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| Production start | `npm run start` |
| Production logs | `journalctl --user -u codeman-web -f` |
**CI**: `.github/workflows/ci.yml` runs `typecheck`, `lint`, `format:check` on push to master (Node 22). Tests excluded (they spawn tmux).
**CI**: `.github/workflows/ci.yml` runs `typecheck`, `lint`, `format:check` on push to master/main and on PRs (Node 22). Tests excluded (they spawn tmux).
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`). ESLint flat config (`eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `tools/**`, `remotion/**`.
@@ -167,7 +167,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### SSE Event Registry
~106 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
~117 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
### API Routes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "0.5.2",
"version": "0.5.4",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+3 -4
View File
@@ -30,6 +30,7 @@ import { join } from 'node:path';
import { EventEmitter } from 'node:events';
import { getAugmentedPath, ANSI_ESCAPE_PATTERN_SIMPLE } from './utils/index.js';
import { AI_CHECK_MAX_BACKOFF_MS } from './config/ai-defaults.js';
import { getErrorMessage } from './types.js';
// ========== Security Validation ==========
@@ -293,7 +294,7 @@ export abstract class AiCheckerBase<
this.emit('checkCompleted', result);
return result;
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
const errorMsg = getErrorMessage(err);
this.handleError(errorMsg);
const result = this.createErrorResult(errorMsg, Date.now() - this.checkStartTime);
this.emit('checkFailed', errorMsg);
@@ -412,9 +413,7 @@ export abstract class AiCheckerBase<
});
muxProcess.unref();
} catch (err) {
throw new Error(
`Failed to spawn ${this.checkDescription} tmux session: ${err instanceof Error ? err.message : String(err)}`
);
throw new Error(`Failed to spawn ${this.checkDescription} tmux session: ${getErrorMessage(err)}`);
}
// Poll the temp file for completion
+102 -118
View File
@@ -470,120 +470,91 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
* Process a single pre-stripped line of terminal output.
*/
private processCleanLine(cleanLine: string): void {
// Check for tool start
if (this._handleToolStart(cleanLine)) return;
if (this._handleToolCompletion(cleanLine)) return;
if (this._handleTextCommand(cleanLine)) return;
this._handleLogFileMention(cleanLine);
}
private _handleToolStart(cleanLine: string): boolean {
const startMatch = cleanLine.match(BASH_TOOL_START_PATTERN);
if (startMatch) {
const command = startMatch[1];
const timeout = startMatch[2]?.trim();
if (!startMatch) return false;
// Check if this is a file-viewing command
if (this.isFileViewerCommand(command)) {
const filePaths = this.extractFilePaths(command);
const command = startMatch[1];
const timeout = startMatch[2]?.trim();
// Skip if any file path is already tracked (cross-pattern dedup)
if (filePaths.some((fp) => this.isFilePathTracked(fp))) {
return;
}
if (!this.isFileViewerCommand(command)) return true;
if (filePaths.length > 0) {
const tool: ActiveBashTool = {
id: uuidv4(),
command,
filePaths,
timeout,
startedAt: Date.now(),
status: 'running',
sessionId: this._sessionId,
};
const filePaths = this.extractFilePaths(command);
// Enforce max tools limit
if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
// Remove oldest tool (O(n) min-scan instead of O(n log n) sort)
let oldestKey: string | undefined;
let oldestTime = Infinity;
for (const [key, entry] of this._activeTools) {
if (entry.startedAt < oldestTime) {
oldestTime = entry.startedAt;
oldestKey = key;
}
}
if (oldestKey) {
this._activeTools.delete(oldestKey);
}
// Skip if any file path is already tracked (cross-pattern dedup)
if (filePaths.some((fp) => this.isFilePathTracked(fp))) return true;
if (filePaths.length > 0) {
const tool = this._createActiveTool(command, filePaths, 'running', timeout);
// Enforce max tools limit
if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
// Remove oldest tool (O(n) min-scan instead of O(n log n) sort)
let oldestKey: string | undefined;
let oldestTime = Infinity;
for (const [key, entry] of this._activeTools) {
if (entry.startedAt < oldestTime) {
oldestTime = entry.startedAt;
oldestKey = key;
}
this._activeTools.set(tool.id, tool);
this._lastToolId = tool.id;
this.emit('toolStart', tool);
this.scheduleUpdate();
}
}
return;
}
// Check for tool completion
if (TOOL_COMPLETION_PATTERN.test(cleanLine) && this._lastToolId) {
const tool = this._activeTools.get(this._lastToolId);
if (tool && tool.status === 'running') {
tool.status = 'completed';
this.emit('toolEnd', tool);
this.scheduleUpdate();
// Remove completed tool after a short delay to allow UI to show completion
this.cleanup.setTimeout(
() => {
if (this._destroyed) return;
this._activeTools.delete(tool.id);
this.scheduleUpdate();
},
2000,
{ description: 'auto-remove completed tool' }
);
}
this._lastToolId = null;
return;
}
// Fallback: Check for command suggestions in plain text (e.g., "tail -f /tmp/file.log")
const textCmdMatch = cleanLine.match(TEXT_COMMAND_PATTERN);
if (textCmdMatch) {
const filePath = textCmdMatch[2];
// Create a suggestion tool (marked as 'suggestion' status)
const tool: ActiveBashTool = {
id: uuidv4(),
command: cleanLine.trim(),
filePaths: [filePath],
timeout: undefined,
startedAt: Date.now(),
status: 'running', // Shows as clickable
sessionId: this._sessionId,
};
// Don't add if file path already tracked (cross-pattern dedup)
if (this.isFilePathTracked(filePath)) {
return;
if (oldestKey) {
this._activeTools.delete(oldestKey);
}
}
this._activeTools.set(tool.id, tool);
this._lastToolId = tool.id;
this.emit('toolStart', tool);
this.scheduleUpdate();
// Auto-remove suggestions after 30 seconds
this.cleanup.setTimeout(
() => {
if (this._destroyed) return;
this._activeTools.delete(tool.id);
this.scheduleUpdate();
},
30000,
{ description: 'auto-remove suggestion tool' }
);
return;
}
// Last fallback: Check for log file paths mentioned anywhere in the line
return true;
}
private _handleToolCompletion(cleanLine: string): boolean {
if (!TOOL_COMPLETION_PATTERN.test(cleanLine) || !this._lastToolId) return false;
const tool = this._activeTools.get(this._lastToolId);
if (tool && tool.status === 'running') {
tool.status = 'completed';
this.emit('toolEnd', tool);
this.scheduleUpdate();
this._scheduleAutoRemove(tool.id, 2000, 'auto-remove completed tool');
}
this._lastToolId = null;
return true;
}
private _handleTextCommand(cleanLine: string): boolean {
const textCmdMatch = cleanLine.match(TEXT_COMMAND_PATTERN);
if (!textCmdMatch) return false;
const filePath = textCmdMatch[2];
// Don't add if file path already tracked (cross-pattern dedup)
if (this.isFilePathTracked(filePath)) return true;
const tool = this._createActiveTool(cleanLine.trim(), [filePath], 'running');
this._activeTools.set(tool.id, tool);
this.emit('toolStart', tool);
this.scheduleUpdate();
// Auto-remove suggestions after 30 seconds
this._scheduleAutoRemove(tool.id, 30000, 'auto-remove suggestion tool');
return true;
}
private _handleLogFileMention(cleanLine: string): void {
LOG_FILE_MENTION_PATTERN.lastIndex = 0;
let logMatch;
while ((logMatch = LOG_FILE_MENTION_PATTERN.exec(cleanLine)) !== null) {
@@ -595,33 +566,46 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
// Skip if file path already tracked (cross-pattern dedup)
if (this.isFilePathTracked(filePath)) continue;
const tool: ActiveBashTool = {
id: uuidv4(),
command: `View: ${filePath}`,
filePaths: [filePath],
timeout: undefined,
startedAt: Date.now(),
status: 'running',
sessionId: this._sessionId,
};
const tool = this._createActiveTool(`View: ${filePath}`, [filePath], 'running');
this._activeTools.set(tool.id, tool);
this.emit('toolStart', tool);
this.scheduleUpdate();
// Auto-remove after 60 seconds
this.cleanup.setTimeout(
() => {
if (this._destroyed) return;
this._activeTools.delete(tool.id);
this.scheduleUpdate();
},
60000,
{ description: 'auto-remove log file tool' }
);
this._scheduleAutoRemove(tool.id, 60000, 'auto-remove log file tool');
}
}
private _createActiveTool(
command: string,
filePaths: string[],
status: ActiveBashTool['status'],
timeout?: string
): ActiveBashTool {
return {
id: uuidv4(),
command,
filePaths,
timeout,
startedAt: Date.now(),
status,
sessionId: this._sessionId,
};
}
private _scheduleAutoRemove(toolId: string, delayMs: number, description: string): void {
this.cleanup.setTimeout(
() => {
if (this._destroyed) return;
this._activeTools.delete(toolId);
this.scheduleUpdate();
},
delayMs,
{ description }
);
}
/**
* Check if a command is a file-viewing command worth tracking.
*/
+2 -4
View File
@@ -16,6 +16,7 @@ import { existsSync, statSync, realpathSync } from 'node:fs';
import { resolve, relative, isAbsolute } from 'node:path';
import { homedir } from 'node:os';
import { EventEmitter } from 'node:events';
import { getErrorMessage } from './types.js';
import { CLEANUP_CHECK_INTERVAL_MS, INACTIVITY_TIMEOUT_MS } from './config/server-timing.js';
// ========== Configuration Constants ==========
@@ -172,10 +173,7 @@ export class FileStreamManager extends EventEmitter {
}
} catch (err) {
const errorCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : 'UNKNOWN';
console.warn(
`[FileStreamManager] Failed to stat file "${absolutePath}" (${errorCode}):`,
err instanceof Error ? err.message : String(err)
);
console.warn(`[FileStreamManager] Failed to stat file "${absolutePath}" (${errorCode}):`, getErrorMessage(err));
return { success: false, error: 'File not found or not accessible' };
}
+31 -23
View File
@@ -499,27 +499,34 @@ export class OrchestratorLoop extends EventEmitter {
}
}
private handleTaskCompleted(queueTaskId: string): void {
private _finalizeTask(queueTaskId: string, status: 'completed' | 'failed', error?: string): OrchestratorTask | null {
const orchTask = this.findOrchestratorTaskByQueueId(queueTaskId);
if (!orchTask) return null;
orchTask.status = status;
if (status === 'completed') {
orchTask.completedAt = Date.now();
this.stats.totalTasksCompleted++;
} else {
orchTask.error = error ?? null;
this.stats.totalTasksFailed++;
}
this.persist();
return orchTask;
}
private handleTaskCompleted(queueTaskId: string): void {
const orchTask = this._finalizeTask(queueTaskId, 'completed');
if (!orchTask) return;
orchTask.status = 'completed';
orchTask.completedAt = Date.now();
this.stats.totalTasksCompleted++;
this.persist();
this.emit('taskCompleted', orchTask);
this.checkPhaseCompletion();
}
private handleTaskFailed(queueTaskId: string, error: string): void {
const orchTask = this.findOrchestratorTaskByQueueId(queueTaskId);
const orchTask = this._finalizeTask(queueTaskId, 'failed', error);
if (!orchTask) return;
orchTask.status = 'failed';
orchTask.error = error;
this.stats.totalTasksFailed++;
this.persist();
this.emit('taskFailed', orchTask, error);
// Check if we should retry the task or fail the phase
@@ -553,19 +560,20 @@ export class OrchestratorLoop extends EventEmitter {
}, this.config.phaseTimeoutMs);
}
private _clearTimer(
timerKey: 'phasePollTimer' | 'phaseTimeoutTimer' | 'postPhaseTimer',
clearFn: typeof clearInterval | typeof clearTimeout
): void {
if (this[timerKey]) {
clearFn(this[timerKey]);
this[timerKey] = null;
}
}
private clearPhasePoll(): void {
if (this.phasePollTimer) {
clearInterval(this.phasePollTimer);
this.phasePollTimer = null;
}
if (this.phaseTimeoutTimer) {
clearTimeout(this.phaseTimeoutTimer);
this.phaseTimeoutTimer = null;
}
if (this.postPhaseTimer) {
clearTimeout(this.postPhaseTimer);
this.postPhaseTimer = null;
}
this._clearTimer('phasePollTimer', clearInterval);
this._clearTimer('phaseTimeoutTimer', clearTimeout);
this._clearTimer('postPhaseTimer', clearTimeout);
}
private pollPhaseStatus(phase: OrchestratorPhase): void {
+9 -3
View File
@@ -20,7 +20,13 @@
*/
import type { Session } from './session.js';
import type { OrchestratorPhase, OrchestratorConfig, VerificationResult, VerificationCheck } from './types.js';
import {
getErrorMessage,
type OrchestratorPhase,
type OrchestratorConfig,
type VerificationResult,
type VerificationCheck,
} from './types.js';
// ═══════════════════════════════════════════════════════════════
// Constants
@@ -111,7 +117,7 @@ export class OrchestratorVerifier {
type: 'test_command',
description: `Run: ${command}`,
passed: false,
output: err instanceof Error ? err.message : String(err),
output: getErrorMessage(err),
});
}
}
@@ -164,7 +170,7 @@ export class OrchestratorVerifier {
type: 'ai_review',
description: `AI review of "${phase.name}"`,
passed: false,
output: `AI review timed out or failed: ${err instanceof Error ? err.message : String(err)}`,
output: `AI review timed out or failed: ${getErrorMessage(err)}`,
};
}
}
+95 -89
View File
@@ -20,7 +20,7 @@ import type { TerminalMultiplexer } from './mux-interface.js';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { RESEARCH_AGENT_PROMPT, PLANNER_PROMPT } from './prompts/index.js';
import type { PlanItem } from './types.js';
import { getErrorMessage, type PlanItem } from './types.js';
// Re-export for backward compatibility
export type { PlanItem };
@@ -231,6 +231,49 @@ export class PlanOrchestrator {
return md;
}
private _extractJsonFromResponse(response: string): string | null {
let jsonMatch = response.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
if (jsonMatch) {
jsonMatch = [jsonMatch[1]]; // Use captured group (inside code block)
} else {
jsonMatch = response.match(/\{[\s\S]*\}/);
}
return jsonMatch ? jsonMatch[0] : null;
}
private _emitAgentFailure(
onSubagent: SubagentCallback | undefined,
agentId: string,
agentType: 'research' | 'planner',
model: string,
error: string,
durationMs: number
): void {
onSubagent?.({
type: 'failed',
agentId,
agentType,
model,
status: 'failed',
error,
durationMs,
});
}
private _formatResearchSection(
parts: string[],
title: string,
items: unknown[],
formatter: (item: unknown) => string[]
): void {
if (items.length === 0) return;
parts.push(title);
for (const item of items.slice(0, 5)) {
parts.push(...formatter(item));
}
parts.push('');
}
async cancel(): Promise<void> {
this.cancelled = true;
// Stop all running sessions and await cleanup to prevent PTY process leaks
@@ -312,7 +355,7 @@ export class PlanOrchestrator {
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : String(err),
error: getErrorMessage(err),
};
}
}
@@ -322,32 +365,23 @@ export class PlanOrchestrator {
const parts: string[] = ['## Research Context\n'];
if (research.findings.externalResources.length > 0) {
parts.push('### External Resources');
for (const r of research.findings.externalResources.slice(0, 5)) {
parts.push(`- ${r.title}${r.url ? ` (${r.url})` : ''}`);
if (r.keyInsights.length > 0) {
parts.push(` Key insights: ${r.keyInsights.slice(0, 3).join(', ')}`);
}
this._formatResearchSection(parts, '### External Resources', research.findings.externalResources, (item) => {
const r = item as ResearchResult['findings']['externalResources'][number];
const lines = [`- ${r.title}${r.url ? ` (${r.url})` : ''}`];
if (r.keyInsights.length > 0) {
lines.push(` Key insights: ${r.keyInsights.slice(0, 3).join(', ')}`);
}
parts.push('');
}
return lines;
});
if (research.findings.codebasePatterns.length > 0) {
parts.push('### Existing Codebase Patterns');
for (const p of research.findings.codebasePatterns.slice(0, 5)) {
parts.push(`- ${p.pattern} at ${p.location}`);
}
parts.push('');
}
this._formatResearchSection(parts, '### Existing Codebase Patterns', research.findings.codebasePatterns, (item) => {
const p = item as ResearchResult['findings']['codebasePatterns'][number];
return [`- ${p.pattern} at ${p.location}`];
});
if (research.findings.technicalRecommendations.length > 0) {
parts.push('### Recommendations');
for (const r of research.findings.technicalRecommendations.slice(0, 5)) {
parts.push(`- ${r}`);
}
parts.push('');
}
this._formatResearchSection(parts, '### Recommendations', research.findings.technicalRecommendations, (item) => [
`- ${item as string}`,
]);
return parts.join('\n');
}
@@ -414,18 +448,20 @@ export class PlanOrchestrator {
const durationMs = Date.now() - startTime;
// Extract JSON from response
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
onSubagent?.({
type: 'failed',
agentId,
agentType: 'research',
model: this.researchModel,
status: 'failed',
error: 'No JSON found',
durationMs,
});
console.log(
`[PlanOrchestrator] Research response length: ${response.length}, first 500 chars:`,
response.substring(0, 500)
);
// Extract JSON from response — try multiple strategies
const jsonStr = this._extractJsonFromResponse(response);
if (!jsonStr) {
console.error(
`[PlanOrchestrator] No JSON found in research response. Full response:`,
response.substring(0, 2000)
);
this._emitAgentFailure(onSubagent, agentId, 'research', this.researchModel, 'No JSON found', durationMs);
return {
success: false,
findings: {
@@ -441,17 +477,9 @@ export class PlanOrchestrator {
};
}
const parsed = tryParseJSON(jsonMatch[0]);
const parsed = tryParseJSON(jsonStr);
if (!parsed.success) {
onSubagent?.({
type: 'failed',
agentId,
agentType: 'research',
model: this.researchModel,
status: 'failed',
error: parsed.error,
durationMs,
});
this._emitAgentFailure(onSubagent, agentId, 'research', this.researchModel, parsed.error!, durationMs);
return {
success: false,
findings: {
@@ -495,16 +523,8 @@ export class PlanOrchestrator {
return result;
} catch (err) {
const durationMs = Date.now() - startTime;
const error = err instanceof Error ? err.message : String(err);
onSubagent?.({
type: 'failed',
agentId,
agentType: 'research',
model: this.researchModel,
status: 'failed',
error,
durationMs,
});
const error = getErrorMessage(err);
this._emitAgentFailure(onSubagent, agentId, 'research', this.researchModel, error, durationMs);
return {
success: false,
findings: {
@@ -587,32 +607,26 @@ export class PlanOrchestrator {
const durationMs = Date.now() - startTime;
// Extract JSON from response
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
onSubagent?.({
type: 'failed',
agentId,
agentType: 'planner',
model: this.plannerModel,
status: 'failed',
error: 'No JSON found',
durationMs,
});
console.log(
`[PlanOrchestrator] Planner response length: ${response.length}, first 500 chars:`,
response.substring(0, 500)
);
// Extract JSON from response — try multiple strategies
const jsonStr = this._extractJsonFromResponse(response);
if (!jsonStr) {
console.error(
`[PlanOrchestrator] No JSON found in planner response. Full response:`,
response.substring(0, 2000)
);
this._emitAgentFailure(onSubagent, agentId, 'planner', this.plannerModel, 'No JSON found', durationMs);
return { success: false, error: 'No JSON in response' };
}
const parsed = tryParseJSON(jsonMatch[0]);
const parsed = tryParseJSON(jsonStr);
if (!parsed.success) {
onSubagent?.({
type: 'failed',
agentId,
agentType: 'planner',
model: this.plannerModel,
status: 'failed',
error: parsed.error,
durationMs,
});
this._emitAgentFailure(onSubagent, agentId, 'planner', this.plannerModel, parsed.error!, durationMs);
return { success: false, error: parsed.error };
}
@@ -637,16 +651,8 @@ export class PlanOrchestrator {
return { success: true, items, gaps, warnings };
} catch (err) {
const durationMs = Date.now() - startTime;
const error = err instanceof Error ? err.message : String(err);
onSubagent?.({
type: 'failed',
agentId,
agentType: 'planner',
model: this.plannerModel,
status: 'failed',
error,
durationMs,
});
const error = getErrorMessage(err);
this._emitAgentFailure(onSubagent, agentId, 'planner', this.plannerModel, error, durationMs);
return { success: false, error };
} finally {
// Always clean up session and progress interval — centralizing here
+109 -107
View File
@@ -91,6 +91,67 @@ const COMPLETION_INDICATOR_PATTERNS = [
/project\s+(?:is\s+)?(?:completed?|done|finished)/i,
];
interface FieldParser<T> {
pattern: RegExp;
field: keyof RalphStatusBlock;
validate: (value: string) => boolean;
transform: (value: string) => T;
errorMsg: (value: string) => string;
}
const FIELD_PARSERS: FieldParser<RalphStatusValue | RalphTestsStatus | RalphWorkType | number | boolean | string>[] = [
{
pattern: RALPH_STATUS_FIELD_PATTERN,
field: 'status',
validate: (v) => ['IN_PROGRESS', 'COMPLETE', 'BLOCKED'].includes(v.toUpperCase()),
transform: (v) => v.toUpperCase() as RalphStatusValue,
errorMsg: (v) => `Invalid STATUS value: "${v}". Expected: IN_PROGRESS, COMPLETE, or BLOCKED`,
},
{
pattern: RALPH_TASKS_COMPLETED_PATTERN,
field: 'tasksCompletedThisLoop',
validate: (v) => !Number.isNaN(parseInt(v, 10)) && parseInt(v, 10) >= 0,
transform: (v) => parseInt(v, 10),
errorMsg: (v) => `Invalid TASKS_COMPLETED_THIS_LOOP value: "${v}". Expected: non-negative integer`,
},
{
pattern: RALPH_FILES_MODIFIED_PATTERN,
field: 'filesModified',
validate: (v) => !Number.isNaN(parseInt(v, 10)) && parseInt(v, 10) >= 0,
transform: (v) => parseInt(v, 10),
errorMsg: (v) => `Invalid FILES_MODIFIED value: "${v}". Expected: non-negative integer`,
},
{
pattern: RALPH_TESTS_STATUS_PATTERN,
field: 'testsStatus',
validate: (v) => ['PASSING', 'FAILING', 'NOT_RUN'].includes(v.toUpperCase()),
transform: (v) => v.toUpperCase() as RalphTestsStatus,
errorMsg: (v) => `Invalid TESTS_STATUS value: "${v}". Expected: PASSING, FAILING, or NOT_RUN`,
},
{
pattern: RALPH_WORK_TYPE_PATTERN,
field: 'workType',
validate: (v) => ['IMPLEMENTATION', 'TESTING', 'DOCUMENTATION', 'REFACTORING'].includes(v.toUpperCase()),
transform: (v) => v.toUpperCase() as RalphWorkType,
errorMsg: (v) =>
`Invalid WORK_TYPE value: "${v}". Expected: IMPLEMENTATION, TESTING, DOCUMENTATION, or REFACTORING`,
},
{
pattern: RALPH_EXIT_SIGNAL_PATTERN,
field: 'exitSignal',
validate: () => true,
transform: (v) => v.toLowerCase() === 'true',
errorMsg: () => '',
},
{
pattern: RALPH_RECOMMENDATION_PATTERN,
field: 'recommendation',
validate: () => true,
transform: (v) => v.trim(),
errorMsg: () => '',
},
];
/**
* RalphStatusParser - Parses RALPH_STATUS blocks and manages circuit breaker.
*
@@ -303,85 +364,21 @@ export class RalphStatusParser extends EventEmitter {
const trimmedLine = line.trim();
if (!trimmedLine) continue;
// Track whether this line matched any known field
let matched = false;
// STATUS field (required)
const statusMatch = trimmedLine.match(RALPH_STATUS_FIELD_PATTERN);
if (statusMatch) {
const value = statusMatch[1].toUpperCase();
if (['IN_PROGRESS', 'COMPLETE', 'BLOCKED'].includes(value)) {
block.status = value as RalphStatusValue;
} else {
parseErrors.push(`Invalid STATUS value: "${value}". Expected: IN_PROGRESS, COMPLETE, or BLOCKED`);
for (const parser of FIELD_PARSERS) {
const match = trimmedLine.match(parser.pattern);
if (match) {
const rawValue = match[1];
if (parser.validate(rawValue)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(block as any)[parser.field] = parser.transform(rawValue);
} else {
parseErrors.push(parser.errorMsg(rawValue));
}
matched = true;
break;
}
matched = true;
}
// TASKS_COMPLETED_THIS_LOOP field
const tasksMatch = trimmedLine.match(RALPH_TASKS_COMPLETED_PATTERN);
if (tasksMatch) {
const value = parseInt(tasksMatch[1], 10);
if (!Number.isNaN(value) && value >= 0) {
block.tasksCompletedThisLoop = value;
} else {
parseErrors.push(
`Invalid TASKS_COMPLETED_THIS_LOOP value: "${tasksMatch[1]}". Expected: non-negative integer`
);
}
matched = true;
}
// FILES_MODIFIED field
const filesMatch = trimmedLine.match(RALPH_FILES_MODIFIED_PATTERN);
if (filesMatch) {
const value = parseInt(filesMatch[1], 10);
if (!Number.isNaN(value) && value >= 0) {
block.filesModified = value;
} else {
parseErrors.push(`Invalid FILES_MODIFIED value: "${filesMatch[1]}". Expected: non-negative integer`);
}
matched = true;
}
// TESTS_STATUS field
const testsMatch = trimmedLine.match(RALPH_TESTS_STATUS_PATTERN);
if (testsMatch) {
const value = testsMatch[1].toUpperCase();
if (['PASSING', 'FAILING', 'NOT_RUN'].includes(value)) {
block.testsStatus = value as RalphTestsStatus;
} else {
parseErrors.push(`Invalid TESTS_STATUS value: "${value}". Expected: PASSING, FAILING, or NOT_RUN`);
}
matched = true;
}
// WORK_TYPE field
const workMatch = trimmedLine.match(RALPH_WORK_TYPE_PATTERN);
if (workMatch) {
const value = workMatch[1].toUpperCase();
if (['IMPLEMENTATION', 'TESTING', 'DOCUMENTATION', 'REFACTORING'].includes(value)) {
block.workType = value as RalphWorkType;
} else {
parseErrors.push(
`Invalid WORK_TYPE value: "${value}". Expected: IMPLEMENTATION, TESTING, DOCUMENTATION, or REFACTORING`
);
}
matched = true;
}
// EXIT_SIGNAL field
const exitMatch = trimmedLine.match(RALPH_EXIT_SIGNAL_PATTERN);
if (exitMatch) {
block.exitSignal = exitMatch[1].toLowerCase() === 'true';
matched = true;
}
// RECOMMENDATION field
const recMatch = trimmedLine.match(RALPH_RECOMMENDATION_PATTERN);
if (recMatch) {
block.recommendation = recMatch[1].trim();
matched = true;
}
// Track unknown fields for debugging (only if looks like a field)
@@ -475,38 +472,9 @@ export class RalphStatusParser extends EventEmitter {
const prevState = this._circuitBreaker.state;
if (hasProgress) {
// Progress detected - reset counters, possibly close circuit
this._circuitBreaker.consecutiveNoProgress = 0;
this._circuitBreaker.consecutiveSameError = 0;
this._circuitBreaker.lastProgressIteration = this._cycleCount;
if (this._circuitBreaker.state === 'HALF_OPEN') {
this._circuitBreaker.state = 'CLOSED';
this._circuitBreaker.reason = 'Progress detected, circuit closed';
this._circuitBreaker.reasonCode = 'progress_detected';
}
this._handleProgressDetected();
} else {
// No progress
this._circuitBreaker.consecutiveNoProgress++;
// State transitions based on consecutive no-progress
if (this._circuitBreaker.state === 'CLOSED') {
if (this._circuitBreaker.consecutiveNoProgress >= 3) {
this._circuitBreaker.state = 'OPEN';
this._circuitBreaker.reason = `No progress for ${this._circuitBreaker.consecutiveNoProgress} iterations`;
this._circuitBreaker.reasonCode = 'no_progress_open';
} else if (this._circuitBreaker.consecutiveNoProgress >= 2) {
this._circuitBreaker.state = 'HALF_OPEN';
this._circuitBreaker.reason = 'Warning: no progress detected';
this._circuitBreaker.reasonCode = 'no_progress_warning';
}
} else if (this._circuitBreaker.state === 'HALF_OPEN') {
if (this._circuitBreaker.consecutiveNoProgress >= 3) {
this._circuitBreaker.state = 'OPEN';
this._circuitBreaker.reason = `No progress for ${this._circuitBreaker.consecutiveNoProgress} iterations`;
this._circuitBreaker.reasonCode = 'no_progress_open';
}
}
this._handleNoProgress();
}
// Track tests failure
@@ -535,6 +503,40 @@ export class RalphStatusParser extends EventEmitter {
}
}
private _handleProgressDetected(): void {
this._circuitBreaker.consecutiveNoProgress = 0;
this._circuitBreaker.consecutiveSameError = 0;
this._circuitBreaker.lastProgressIteration = this._cycleCount;
if (this._circuitBreaker.state === 'HALF_OPEN') {
this._circuitBreaker.state = 'CLOSED';
this._circuitBreaker.reason = 'Progress detected, circuit closed';
this._circuitBreaker.reasonCode = 'progress_detected';
}
}
private _handleNoProgress(): void {
this._circuitBreaker.consecutiveNoProgress++;
if (this._circuitBreaker.state === 'CLOSED') {
if (this._circuitBreaker.consecutiveNoProgress >= 3) {
this._circuitBreaker.state = 'OPEN';
this._circuitBreaker.reason = `No progress for ${this._circuitBreaker.consecutiveNoProgress} iterations`;
this._circuitBreaker.reasonCode = 'no_progress_open';
} else if (this._circuitBreaker.consecutiveNoProgress >= 2) {
this._circuitBreaker.state = 'HALF_OPEN';
this._circuitBreaker.reason = 'Warning: no progress detected';
this._circuitBreaker.reasonCode = 'no_progress_warning';
}
} else if (this._circuitBreaker.state === 'HALF_OPEN') {
if (this._circuitBreaker.consecutiveNoProgress >= 3) {
this._circuitBreaker.state = 'OPEN';
this._circuitBreaker.reason = `No progress for ${this._circuitBreaker.consecutiveNoProgress} iterations`;
this._circuitBreaker.reasonCode = 'no_progress_open';
}
}
}
/**
* Check line for completion indicators (natural language patterns).
* Used for dual-condition exit gate.
+58 -66
View File
@@ -100,6 +100,18 @@ const TODO_CLEANUP_INTERVAL_MS = INACTIVITY_TIMEOUT_MS;
*/
const TODO_SIMILARITY_THRESHOLD = 0.85;
/**
* Similarity threshold for short todo content (<30 chars).
* Higher threshold reduces false positive deduplication of short strings.
*/
const SIMILARITY_THRESHOLD_SHORT = 0.95;
/**
* Similarity threshold for medium-length todo content (30-60 chars).
* Slightly relaxed compared to short strings.
*/
const SIMILARITY_THRESHOLD_MEDIUM = 0.9;
/**
* Debounce interval for event emissions (milliseconds).
* Prevents UI jitter from rapid consecutive updates.
@@ -1299,6 +1311,24 @@ export class RalphTracker extends EventEmitter {
this.detectTodoItems(trimmed);
}
/**
* Mark all tracked todos as completed and emit todoUpdate if any changed.
* @returns true if any todo was updated
*/
private completeAllTodos(): boolean {
let updated = false;
for (const todo of this._todos.values()) {
if (todo.status !== 'completed') {
todo.status = 'completed';
updated = true;
}
}
if (updated) {
this.emit('todoUpdate', this.todos);
}
return updated;
}
/**
* Detect "all tasks complete" messages.
*/
@@ -1318,16 +1348,7 @@ export class RalphTracker extends EventEmitter {
return;
}
let updated = false;
for (const todo of this._todos.values()) {
if (todo.status !== 'completed') {
todo.status = 'completed';
updated = true;
}
}
if (updated) {
this.emit('todoUpdate', this.todos);
}
this.completeAllTodos();
if (this._loopState.completionPhrase) {
this._loopState.active = false;
@@ -1425,16 +1446,7 @@ export class RalphTracker extends EventEmitter {
if (bareCount > 1) return;
let updated = false;
for (const todo of this._todos.values()) {
if (todo.status !== 'completed') {
todo.status = 'completed';
updated = true;
}
}
if (updated) {
this.emit('todoUpdate', this.todos);
}
this.completeAllTodos();
this._loopState.active = false;
this._loopState.lastActivity = Date.now();
@@ -1480,16 +1492,7 @@ export class RalphTracker extends EventEmitter {
if (canonicalCount >= 2 || this._loopState.active) {
this._loopState.active = false;
this._loopState.lastActivity = Date.now();
let updated = false;
for (const todo of this._todos.values()) {
if (todo.status !== 'completed') {
todo.status = 'completed';
updated = true;
}
}
if (updated) {
this.emit('todoUpdate', this.todos);
}
this.completeAllTodos();
this.emit('completionDetected', matchedPhrase);
this.emit('loopUpdate', this.loopState);
return;
@@ -1497,16 +1500,7 @@ export class RalphTracker extends EventEmitter {
}
if (this._loopState.active || count >= 2) {
let updated = false;
for (const todo of this._todos.values()) {
if (todo.status !== 'completed') {
todo.status = 'completed';
updated = true;
}
}
if (updated) {
this.emit('todoUpdate', this.todos);
}
this.completeAllTodos();
this._loopState.active = false;
this._loopState.lastActivity = Date.now();
@@ -1532,41 +1526,39 @@ export class RalphTracker extends EventEmitter {
const suggestedPhrase = `${phrase}_${uniqueSuffix}`;
if (COMMON_COMPLETION_PHRASES.has(normalized)) {
console.warn(
`[RalphTracker] Warning: Completion phrase "${phrase}" is very common and may cause false positives. Consider using: "${suggestedPhrase}"`
);
this.emit('phraseValidationWarning', {
phrase,
reason: 'common',
suggestedPhrase,
});
this.emitValidationWarning(phrase, 'common', suggestedPhrase);
return;
}
if (normalized.length < MIN_RECOMMENDED_PHRASE_LENGTH) {
console.warn(
`[RalphTracker] Warning: Completion phrase "${phrase}" is too short (${normalized.length} chars). Consider using: "${suggestedPhrase}"`
);
this.emit('phraseValidationWarning', {
phrase,
reason: 'short',
suggestedPhrase,
});
this.emitValidationWarning(phrase, 'short', suggestedPhrase);
return;
}
if (/^\d+$/.test(normalized)) {
console.warn(
`[RalphTracker] Warning: Completion phrase "${phrase}" is numeric-only and may cause false positives. Consider using: "${suggestedPhrase}"`
);
this.emit('phraseValidationWarning', {
phrase,
reason: 'numeric',
suggestedPhrase,
});
this.emitValidationWarning(phrase, 'numeric', suggestedPhrase);
}
}
/**
* Emit a phrase validation warning with a console message and event.
*/
private emitValidationWarning(phrase: string, reason: 'common' | 'short' | 'numeric', suggestedPhrase: string): void {
const descriptions: Record<'common' | 'short' | 'numeric', string> = {
common: 'is very common and may cause false positives',
short: `is too short (${phrase.toUpperCase().replace(/[\s_\-.]+/g, '').length} chars)`,
numeric: 'is numeric-only and may cause false positives',
};
console.warn(
`[RalphTracker] Warning: Completion phrase "${phrase}" ${descriptions[reason]}. Consider using: "${suggestedPhrase}"`
);
this.emit('phraseValidationWarning', {
phrase,
reason,
suggestedPhrase,
});
}
/**
* Activate the loop if not already active.
*/
@@ -1977,9 +1969,9 @@ export class RalphTracker extends EventEmitter {
let threshold: number;
if (normalized.length < 30) {
threshold = 0.95;
threshold = SIMILARITY_THRESHOLD_SHORT;
} else if (normalized.length < 60) {
threshold = 0.9;
threshold = SIMILARITY_THRESHOLD_MEDIUM;
} else {
threshold = TODO_SIMILARITY_THRESHOLD;
}
+240 -190
View File
@@ -70,12 +70,13 @@ import {
AI_PLAN_CHECK_TIMEOUT_MS,
AI_PLAN_CHECK_COOLDOWN_MS,
} from './config/ai-defaults.js';
import type {
RespawnCycleMetrics,
RespawnAggregateMetrics,
RalphLoopHealthScore,
TimingHistory,
CycleOutcome,
import {
getErrorMessage,
type RespawnCycleMetrics,
type RespawnAggregateMetrics,
type RalphLoopHealthScore,
type TimingHistory,
type CycleOutcome,
} from './types.js';
// ========== Constants ==========
@@ -551,6 +552,14 @@ export interface RespawnEvents {
respawnBlocked: (data: { reason: string; details: string }) => void;
}
/**
* Convert milliseconds to a non-negative whole number of seconds for countdown display.
* Rounds up so that e.g. 1200 ms shows as 2 s (never under-reports remaining time).
*/
function formatRemainingSeconds(ms: number): number {
return Math.max(0, Math.ceil(ms / 1000));
}
/** Default configuration values */
const DEFAULT_CONFIG: RespawnConfig = {
idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback)
@@ -839,27 +848,42 @@ export class RespawnController extends EventEmitter {
private validateConfig(): void {
const c = this.config;
// Ensure timeouts are positive
if (c.idleTimeoutMs <= 0) c.idleTimeoutMs = DEFAULT_CONFIG.idleTimeoutMs;
if (c.completionConfirmMs <= 0) c.completionConfirmMs = DEFAULT_CONFIG.completionConfirmMs;
if (c.noOutputTimeoutMs <= 0) c.noOutputTimeoutMs = DEFAULT_CONFIG.noOutputTimeoutMs;
if (c.autoAcceptDelayMs < 0) c.autoAcceptDelayMs = DEFAULT_CONFIG.autoAcceptDelayMs;
if (c.interStepDelayMs <= 0) c.interStepDelayMs = DEFAULT_CONFIG.interStepDelayMs;
/**
* Validate that a timeout value is positive (or non-negative when allowZero is true).
* Falls back to the DEFAULT_CONFIG value if invalid.
*/
const validatePositiveTimeout = (field: keyof RespawnConfig, allowZero = false): void => {
const value = c[field] as number;
const invalid = allowZero ? value < 0 : value <= 0;
if (invalid) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(c as any)[field] = DEFAULT_CONFIG[field];
}
};
const REQUIRED_TIMEOUT_FIELDS = [
'idleTimeoutMs',
'completionConfirmMs',
'noOutputTimeoutMs',
'interStepDelayMs',
'aiIdleCheckTimeoutMs',
'aiIdleCheckMaxContext',
'aiPlanCheckTimeoutMs',
'aiPlanCheckMaxContext',
] as const;
for (const field of REQUIRED_TIMEOUT_FIELDS) {
validatePositiveTimeout(field);
}
const ALLOW_ZERO_FIELDS = ['autoAcceptDelayMs', 'aiIdleCheckCooldownMs', 'aiPlanCheckCooldownMs'] as const;
for (const field of ALLOW_ZERO_FIELDS) {
validatePositiveTimeout(field, true);
}
// Ensure completion confirm doesn't exceed no-output timeout
if (c.completionConfirmMs > c.noOutputTimeoutMs) {
c.completionConfirmMs = c.noOutputTimeoutMs;
}
// Ensure AI check timeouts are positive
if (c.aiIdleCheckTimeoutMs <= 0) c.aiIdleCheckTimeoutMs = DEFAULT_CONFIG.aiIdleCheckTimeoutMs;
if (c.aiIdleCheckCooldownMs < 0) c.aiIdleCheckCooldownMs = DEFAULT_CONFIG.aiIdleCheckCooldownMs;
if (c.aiIdleCheckMaxContext <= 0) c.aiIdleCheckMaxContext = DEFAULT_CONFIG.aiIdleCheckMaxContext;
// Ensure plan check timeouts are positive
if (c.aiPlanCheckTimeoutMs <= 0) c.aiPlanCheckTimeoutMs = DEFAULT_CONFIG.aiPlanCheckTimeoutMs;
if (c.aiPlanCheckCooldownMs < 0) c.aiPlanCheckCooldownMs = DEFAULT_CONFIG.aiPlanCheckCooldownMs;
if (c.aiPlanCheckMaxContext <= 0) c.aiPlanCheckMaxContext = DEFAULT_CONFIG.aiPlanCheckMaxContext;
}
/** Wire up AI checker events to controller events (removes existing listeners first to prevent duplicates) */
@@ -987,11 +1011,11 @@ export class RespawnController extends EventEmitter {
waitingFor = 'AI verdict (IDLE or WORKING)';
} else if (this._state === 'confirming_idle') {
statusText = `Confirming idle (${confidence}% confidence)`;
waitingFor = `${Math.max(0, Math.ceil((this.config.completionConfirmMs - msSinceLastOutput) / 1000))}s more silence`;
waitingFor = `${formatRemainingSeconds(this.config.completionConfirmMs - msSinceLastOutput)}s more silence`;
} else if (this._state === 'watching') {
const aiState = this.aiChecker.getState();
if (aiState.status === 'cooldown') {
const remaining = Math.ceil(this.aiChecker.getCooldownRemainingMs() / 1000);
const remaining = formatRemainingSeconds(this.aiChecker.getCooldownRemainingMs());
statusText = `AI Check: WORKING (cooldown ${remaining}s)`;
waitingFor = 'Cooldown to expire';
} else if (completionMessageDetected) {
@@ -1364,98 +1388,13 @@ export class RespawnController extends EventEmitter {
this.lastTokenChangeTime = now;
}
// Detect completion message FIRST (Layer 1) - PRIMARY DETECTION
// Check this before working patterns because completion message indicates
// the work is done, even if working patterns are still in the rolling window
if (isCompletionMessage(data)) {
// Clear the rolling window - completion marks a transition point
this.clearWorkingPatternWindow();
this.workingDetected = false;
this.completionMessageTime = now;
this.cancelAutoAcceptTimer(); // Normal idle flow handles this
this.log(`Completion message detected: "${data.trim().substring(0, 50)}..."`);
// Layer 1: Completion message (PRIMARY) — checked before working patterns
if (this._detectCompletionMessage(data, now)) return;
// In watching state, start completion confirmation timer
if (this._state === 'watching') {
this.startCompletionConfirmTimer();
return;
}
// Layer 4: Working patterns
if (this._detectWorkingPattern(data, now)) return;
// In waiting states, also use confirmation timer (same detection logic)
// This ensures we wait for Claude to finish before proceeding
// Note: 'watching' is already handled above and returns early
switch (this._state) {
case 'waiting_update':
this.startStepConfirmTimer('update');
break;
case 'waiting_clear':
this.checkClearComplete(); // /clear is quick, no need to wait
break;
case 'waiting_init':
this.startStepConfirmTimer('init');
break;
case 'waiting_kickstart':
this.startStepConfirmTimer('kickstart');
break;
// Non-waiting states: completion message is ignored
case 'confirming_idle':
case 'ai_checking':
case 'sending_update':
case 'sending_clear':
case 'sending_init':
case 'monitoring_init':
case 'sending_kickstart':
case 'stopped':
// Completion message during these states is ignored
break;
default:
assertNever(this._state, `Unhandled RespawnState in completion detection: ${this._state}`);
}
return;
}
// Detect working patterns (Layer 4)
const isWorking = this.checkWorkingPattern(data);
if (isWorking) {
this.workingDetected = true;
this.promptDetected = false;
this.elicitationDetected = false; // Clear on new work cycle
this.resetHookState(); // Clear hook signals on new work
this.lastWorkingPatternTime = now;
// Cancel hook confirmation timer if running
this.cancelTrackedTimer('hook-confirm', 'working patterns detected');
// Cancel any pending completion confirmation
this.cancelCompletionConfirm();
// Cancel any pending step confirmation (Claude is still working)
this.cancelStepConfirm();
// If AI check is running, cancel it (Claude is working)
if (this._state === 'ai_checking') {
this.log('Working patterns detected during AI check, cancelling');
this.aiChecker.cancel();
this.setState('watching');
}
// Cancel plan check if running (Claude started working)
if (this.planChecker.status === 'checking') {
this.log('Working patterns detected during plan check, cancelling');
this.planChecker.cancel();
}
// If we're monitoring init and work started, go to watching (no kickstart needed)
if (this._state === 'monitoring_init') {
this.log('/init triggered work, skipping kickstart');
this.emit('stepCompleted', 'init');
this.completeCycle();
}
return;
}
// In confirming_idle or ai_checking state, substantial output cancels the flow.
// This prevents false triggers when Claude pauses briefly mid-work.
// Substantial output during confirming_idle/ai_checking cancels the flow
if (this._state === 'confirming_idle' || this._state === 'ai_checking') {
// Strip ANSI escape codes to check if there's real content
ANSI_ESCAPE_PATTERN_SIMPLE.lastIndex = 0;
@@ -1476,43 +1415,137 @@ export class RespawnController extends EventEmitter {
}
}
// Legacy fallback: detect prompt characters (still useful for waiting_* states)
const hasPrompt = PROMPT_PATTERNS.some((pattern) => data.includes(pattern));
if (hasPrompt) {
this.promptDetected = true;
this.workingDetected = false;
// Legacy fallback: prompt detection
this._detectPrompt(data);
}
// Handle legacy detection in waiting states - also use confirmation timers
switch (this._state) {
case 'waiting_update':
this.startStepConfirmTimer('update');
break;
case 'waiting_clear':
this.checkClearComplete(); // /clear is quick, no need to wait
break;
case 'waiting_init':
this.startStepConfirmTimer('init');
break;
case 'monitoring_init':
this.checkMonitoringInitIdle();
break;
case 'waiting_kickstart':
this.startStepConfirmTimer('kickstart');
break;
// Non-waiting states: prompt detection is informational only
case 'watching':
case 'confirming_idle':
case 'ai_checking':
case 'sending_update':
case 'sending_clear':
case 'sending_init':
case 'sending_kickstart':
case 'stopped':
// Prompt detection during these states doesn't trigger action
break;
default:
assertNever(this._state, `Unhandled RespawnState in prompt detection: ${this._state}`);
}
private _detectCompletionMessage(data: string, now: number): boolean {
if (!isCompletionMessage(data)) return false;
// Clear the rolling window - completion marks a transition point
this.clearWorkingPatternWindow();
this.workingDetected = false;
this.completionMessageTime = now;
this.cancelAutoAcceptTimer(); // Normal idle flow handles this
this.log(`Completion message detected: "${data.trim().substring(0, 50)}..."`);
// In watching state, start completion confirmation timer
if (this._state === 'watching') {
this.startCompletionConfirmTimer();
return true;
}
// In waiting states, also use confirmation timer (same detection logic)
// This ensures we wait for Claude to finish before proceeding
// Note: 'watching' is already handled above and returns early
switch (this._state) {
case 'waiting_update':
this.startStepConfirmTimer('update');
break;
case 'waiting_clear':
this.checkClearComplete(); // /clear is quick, no need to wait
break;
case 'waiting_init':
this.startStepConfirmTimer('init');
break;
case 'waiting_kickstart':
this.startStepConfirmTimer('kickstart');
break;
// Non-waiting states: completion message is ignored
case 'confirming_idle':
case 'ai_checking':
case 'sending_update':
case 'sending_clear':
case 'sending_init':
case 'monitoring_init':
case 'sending_kickstart':
case 'stopped':
// Completion message during these states is ignored
break;
default:
assertNever(this._state, `Unhandled RespawnState in completion detection: ${this._state}`);
}
return true;
}
private _detectWorkingPattern(data: string, now: number): boolean {
const isWorking = this.checkWorkingPattern(data);
if (!isWorking) return false;
this.workingDetected = true;
this.promptDetected = false;
this.elicitationDetected = false; // Clear on new work cycle
this.resetHookState(); // Clear hook signals on new work
this.lastWorkingPatternTime = now;
// Cancel hook confirmation timer if running
this.cancelTrackedTimer('hook-confirm', 'working patterns detected');
// Cancel any pending completion confirmation
this.cancelCompletionConfirm();
// Cancel any pending step confirmation (Claude is still working)
this.cancelStepConfirm();
// If AI check is running, cancel it (Claude is working)
if (this._state === 'ai_checking') {
this.log('Working patterns detected during AI check, cancelling');
this.aiChecker.cancel();
this.setState('watching');
}
// Cancel plan check if running (Claude started working)
if (this.planChecker.status === 'checking') {
this.log('Working patterns detected during plan check, cancelling');
this.planChecker.cancel();
}
// If we're monitoring init and work started, go to watching (no kickstart needed)
if (this._state === 'monitoring_init') {
this.log('/init triggered work, skipping kickstart');
this.emit('stepCompleted', 'init');
this.completeCycle();
}
return true;
}
private _detectPrompt(data: string): void {
const hasPrompt = PROMPT_PATTERNS.some((pattern) => data.includes(pattern));
if (!hasPrompt) return;
this.promptDetected = true;
this.workingDetected = false;
// Handle legacy detection in waiting states - also use confirmation timers
switch (this._state) {
case 'waiting_update':
this.startStepConfirmTimer('update');
break;
case 'waiting_clear':
this.checkClearComplete(); // /clear is quick, no need to wait
break;
case 'waiting_init':
this.startStepConfirmTimer('init');
break;
case 'monitoring_init':
this.checkMonitoringInitIdle();
break;
case 'waiting_kickstart':
this.startStepConfirmTimer('kickstart');
break;
// Non-waiting states: prompt detection is informational only
case 'watching':
case 'confirming_idle':
case 'ai_checking':
case 'sending_update':
case 'sending_clear':
case 'sending_init':
case 'sending_kickstart':
case 'stopped':
// Prompt detection during these states doesn't trigger action
break;
default:
assertNever(this._state, `Unhandled RespawnState in prompt detection: ${this._state}`);
}
}
@@ -1798,24 +1831,28 @@ export class RespawnController extends EventEmitter {
case 'sending_init':
case 'sending_kickstart':
// For sending states, retry the send
this.log('Recovery: returning to watching state');
this.setState('watching');
this.startNoOutputTimer();
this.startPreFilterTimer();
if (this.config.autoAcceptPrompts) {
this.startAutoAcceptTimer();
}
this.recoveryResetToWatching('returning to watching state');
break;
default:
// Fallback: reset to watching
this.log('Recovery: fallback to watching state');
this.setState('watching');
this.startNoOutputTimer();
this.startPreFilterTimer();
if (this.config.autoAcceptPrompts) {
this.startAutoAcceptTimer();
}
this.recoveryResetToWatching('fallback to watching state');
}
}
/**
* Reset the controller to watching state during stuck-state recovery.
* Sets state to watching and restarts all detection timers.
*
* @param reason - Human-readable reason for the reset (logged)
*/
private recoveryResetToWatching(reason: string): void {
this.log(`Recovery: ${reason}`);
this.setState('watching');
this.startNoOutputTimer();
this.startPreFilterTimer();
if (this.config.autoAcceptPrompts) {
this.startAutoAcceptTimer();
}
}
@@ -2051,7 +2088,7 @@ export class RespawnController extends EventEmitter {
// If on cooldown, don't start check - wait for cooldown to expire
if (this.aiChecker.isOnCooldown()) {
this.log(
`AI check on cooldown (${Math.ceil(this.aiChecker.getCooldownRemainingMs() / 1000)}s remaining), waiting...`
`AI check on cooldown (${formatRemainingSeconds(this.aiChecker.getCooldownRemainingMs())}s remaining), waiting...`
);
return;
}
@@ -2136,7 +2173,7 @@ export class RespawnController extends EventEmitter {
}
if (this._state === 'stopped') return; // Guard against stopped state
if (this._state === 'ai_checking') {
const errorMsg = err instanceof Error ? err.message : String(err);
const errorMsg = getErrorMessage(err);
this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`);
this.emit('aiCheckFailed', errorMsg);
this.setState('watching');
@@ -2198,36 +2235,15 @@ export class RespawnController extends EventEmitter {
* @fires planCheckStarted
*/
private tryAutoAccept(): void {
// Only auto-accept in watching state (not during a respawn cycle)
if (this._state !== 'watching') return;
if (!this.canAutoAccept()) return;
// Don't auto-accept if a completion message was detected (normal idle handles it)
if (this.completionMessageTime !== null) return;
// Don't auto-accept if disabled
if (!this.config.autoAcceptPrompts) return;
// Don't auto-accept if we haven't received any output yet (prevents spurious Enter on fresh start)
if (!this.hasReceivedOutput) return;
// Don't auto-accept if an elicitation dialog (AskUserQuestion) was detected
if (this.elicitationDetected) {
this.log('Skipping auto-accept: elicitation dialog detected (AskUserQuestion)');
return;
}
// Stage 1: Pre-filter — check if buffer looks like plan mode
const buffer = this.terminalBuffer.value;
if (!this.isPlanModePreFilterMatch(buffer)) {
this.log('Skipping auto-accept: pre-filter did not match plan mode patterns');
return;
}
// Stage 2: AI confirmation (if enabled and available)
if (this.config.aiPlanCheckEnabled && this.planChecker.status !== 'disabled') {
if (this.planChecker.isOnCooldown()) {
this.log(
`Skipping auto-accept: plan checker on cooldown (${Math.ceil(this.planChecker.getCooldownRemainingMs() / 1000)}s remaining)`
`Skipping auto-accept: plan checker on cooldown (${formatRemainingSeconds(this.planChecker.getCooldownRemainingMs())}s remaining)`
);
return;
}
@@ -2244,6 +2260,40 @@ export class RespawnController extends EventEmitter {
this.sendAutoAcceptEnter();
}
/**
* Check whether all preconditions for auto-accept are met.
* Validates state, config, and pre-filter conditions before attempting auto-accept.
*
* @returns True if auto-accept should proceed to the AI confirmation stage
*/
private canAutoAccept(): boolean {
// Only auto-accept in watching state (not during a respawn cycle)
if (this._state !== 'watching') return false;
// Don't auto-accept if a completion message was detected (normal idle handles it)
if (this.completionMessageTime !== null) return false;
// Don't auto-accept if disabled
if (!this.config.autoAcceptPrompts) return false;
// Don't auto-accept if we haven't received any output yet (prevents spurious Enter on fresh start)
if (!this.hasReceivedOutput) return false;
// Don't auto-accept if an elicitation dialog (AskUserQuestion) was detected
if (this.elicitationDetected) {
this.log('Skipping auto-accept: elicitation dialog detected (AskUserQuestion)');
return false;
}
// Stage 1: Pre-filter — check if buffer looks like plan mode
if (!this.isPlanModePreFilterMatch(this.terminalBuffer.value)) {
this.log('Skipping auto-accept: pre-filter did not match plan mode patterns');
return false;
}
return true;
}
/**
* Check if the terminal buffer matches plan mode pre-filter patterns.
* Only checks the last 2000 chars (plan mode UI appears at the bottom).
@@ -2322,7 +2372,7 @@ export class RespawnController extends EventEmitter {
}
})
.catch((err) => {
const errorMsg = err instanceof Error ? err.message : String(err);
const errorMsg = getErrorMessage(err);
this.emit('planCheckFailed', errorMsg);
this.logAction('plan-check', `Failed: ${errorMsg.substring(0, 50)}`);
});
+98 -51
View File
@@ -27,6 +27,57 @@ const COMPACT_COOLDOWN_MS = 10000;
/** Cooldown after clear completes before re-enabling (5 seconds) */
const CLEAR_COOLDOWN_MS = 5000;
/**
* Executes an action when the session becomes idle, retrying if currently working.
*
* @param action - The async action to execute once idle
* @param isActive - Returns whether this operation is still active (not cancelled)
* @param isWorking - Returns whether the session is currently working
* @param isStopped - Returns whether the session has been stopped
* @param retryMs - Delay between retry attempts when working
* @param cooldownMs - Delay after action completes before calling onCooldownDone
* @param setTimer - Stores the timer reference for cleanup
* @param onCooldownDone - Called after cooldown to reset state
*/
async function executeWhenIdle(
action: () => Promise<void>,
isActive: () => boolean,
isWorking: () => boolean,
isStopped: () => boolean,
retryMs: number,
cooldownMs: number,
setTimer: (timer: NodeJS.Timeout | null) => void,
onCooldownDone: () => void
): Promise<void> {
if (isStopped()) return;
if (!isActive()) return;
if (!isWorking()) {
if (isStopped()) return;
await action();
if (!isStopped()) {
setTimer(
setTimeout(() => {
if (isStopped()) return;
setTimer(null);
onCooldownDone();
}, cooldownMs)
);
}
} else {
if (!isStopped()) {
setTimer(
setTimeout(
() => executeWhenIdle(action, isActive, isWorking, isStopped, retryMs, cooldownMs, setTimer, onCooldownDone),
retryMs
)
);
}
}
}
/** Minimum valid threshold for auto-clear/compact (1000 tokens) */
const MIN_AUTO_THRESHOLD = 1000;
@@ -181,37 +232,35 @@ export class SessionAutoOps extends EventEmitter {
`[SessionAutoOps] Auto-compact triggered: ${totalTokens} tokens >= ${this._autoCompactThreshold} threshold`
);
const checkAndCompact = async () => {
if (this.callbacks.isStopped()) return;
if (!this._isCompacting) return;
if (!this.callbacks.isWorking()) {
if (this.callbacks.isStopped()) return;
const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r';
await this.callbacks.writeCommand(compactCmd);
this.emit('autoCompact', {
tokens: totalTokens,
threshold: this._autoCompactThreshold,
prompt: this._autoCompactPrompt || undefined,
});
if (!this.callbacks.isStopped()) {
this._autoCompactTimer = setTimeout(() => {
if (this.callbacks.isStopped()) return;
this._autoCompactTimer = null;
this._isCompacting = false;
}, COMPACT_COOLDOWN_MS);
}
} else {
if (!this.callbacks.isStopped()) {
this._autoCompactTimer = setTimeout(checkAndCompact, AUTO_RETRY_DELAY_MS);
}
}
const action = async () => {
const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r';
await this.callbacks.writeCommand(compactCmd);
this.emit('autoCompact', {
tokens: totalTokens,
threshold: this._autoCompactThreshold,
prompt: this._autoCompactPrompt || undefined,
});
};
if (!this.callbacks.isStopped()) {
this._autoCompactTimer = setTimeout(checkAndCompact, AUTO_INITIAL_DELAY_MS);
this._autoCompactTimer = setTimeout(
() =>
executeWhenIdle(
action,
() => this._isCompacting,
() => this.callbacks.isWorking(),
() => this.callbacks.isStopped(),
AUTO_RETRY_DELAY_MS,
COMPACT_COOLDOWN_MS,
(timer) => {
this._autoCompactTimer = timer;
},
() => {
this._isCompacting = false;
}
),
AUTO_INITIAL_DELAY_MS
);
}
}
}
@@ -231,32 +280,30 @@ export class SessionAutoOps extends EventEmitter {
`[SessionAutoOps] Auto-clear triggered: ${totalTokens} tokens >= ${this._autoClearThreshold} threshold`
);
const checkAndClear = async () => {
if (this.callbacks.isStopped()) return;
if (!this._isClearing) return;
if (!this.callbacks.isWorking()) {
if (this.callbacks.isStopped()) return;
await this.callbacks.writeCommand('/clear\r');
this.emit('autoClear', { tokens: totalTokens, threshold: this._autoClearThreshold });
if (!this.callbacks.isStopped()) {
this._autoClearTimer = setTimeout(() => {
if (this.callbacks.isStopped()) return;
this._autoClearTimer = null;
this._isClearing = false;
}, CLEAR_COOLDOWN_MS);
}
} else {
if (!this.callbacks.isStopped()) {
this._autoClearTimer = setTimeout(checkAndClear, AUTO_RETRY_DELAY_MS);
}
}
const action = async () => {
await this.callbacks.writeCommand('/clear\r');
this.emit('autoClear', { tokens: totalTokens, threshold: this._autoClearThreshold });
};
if (!this.callbacks.isStopped()) {
this._autoClearTimer = setTimeout(checkAndClear, AUTO_INITIAL_DELAY_MS);
this._autoClearTimer = setTimeout(
() =>
executeWhenIdle(
action,
() => this._isClearing,
() => this.callbacks.isWorking(),
() => this.callbacks.isStopped(),
AUTO_RETRY_DELAY_MS,
CLEAR_COOLDOWN_MS,
(timer) => {
this._autoClearTimer = timer;
},
() => {
this._isClearing = false;
}
),
AUTO_INITIAL_DELAY_MS
);
}
}
}
+208 -243
View File
@@ -40,6 +40,7 @@ import {
ActiveBashTool,
NiceConfig,
DEFAULT_NICE_CONFIG,
getErrorMessage,
type ClaudeMode,
type SessionMode,
type OpenCodeConfig,
@@ -871,18 +872,77 @@ export class Session extends EventEmitter {
* session.write('help me with this code\r');
* ```
*/
private async _setupOrAttachMuxSession(options: {
respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions;
createSessionOptions: import('./mux-interface.js').CreateSessionOptions;
spawnErrLabel: string;
}): Promise<{ isRestored: boolean }> {
const mux = this._mux!;
// Verify stale mux session — tmux may have been destroyed (e.g., killed externally)
if (this._muxSession && !mux.muxSessionExists(this._muxSession.muxName)) {
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
this._muxSession = null;
}
// Check if session exists but pane is dead (remain-on-exit keeps it alive)
// Respawn the pane instead of creating a whole new session — preserves tmux scrollback
let needsNewSession = false;
if (this._muxSession && mux.isPaneDead(this._muxSession.muxName)) {
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
const newPid = await mux.respawnPane(options.respawnPaneOptions);
if (!newPid) {
console.error('[Session] Failed to respawn pane, will create new session');
needsNewSession = true;
} else {
// Wait a moment for the respawned process to fully start
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
}
}
// Check if we already have a mux session (restored session)
const isRestored = this._muxSession !== null && !needsNewSession;
if (isRestored) {
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
} else {
// Create a new mux session
this._muxSession = await mux.createSession(options.createSessionOptions);
console.log('[Session] Created mux session:', this._muxSession.muxName);
// No extra sleep — createSession() already waits for tmux readiness
}
// Attach to the mux session via PTY
try {
this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), {
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: this.workingDir,
env: buildMuxAttachEnv(),
});
} catch (spawnErr) {
console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr);
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
throw spawnErr;
}
return { isRestored };
}
private _handleTerminalOutput(data: string): void {
// BufferAccumulator handles auto-trimming when max size exceeded
this._terminalBuffer.append(data);
this._lastActivityAt = Date.now();
this.emit('terminal', data);
this.emit('output', data);
}
async startInteractive(): Promise<void> {
if (this.ptyProcess) {
throw new Error('Session already has a running process');
}
this._status = 'busy';
this._terminalBuffer.clear();
this._textOutput.clear();
this._errorBuffer = '';
this._messages = [];
this._lineBuffer = '';
this._lastActivityAt = Date.now();
this._resetBuffers();
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : 'Claude';
console.log(
@@ -892,18 +952,8 @@ export class Session extends EventEmitter {
// If mux wrapping is enabled, create or attach to a mux session
if (this._useMux && this._mux) {
try {
// Verify stale mux session — tmux may have been destroyed (e.g., killed externally)
if (this._muxSession && !this._mux.muxSessionExists(this._muxSession.muxName)) {
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
this._muxSession = null;
}
// Check if session exists but pane is dead (remain-on-exit keeps it alive)
// Respawn the pane instead of creating a whole new session — preserves tmux scrollback
let needsNewSession = false;
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
const newPid = await this._mux.respawnPane({
const { isRestored } = await this._setupOrAttachMuxSession({
respawnPaneOptions: {
sessionId: this.id,
workingDir: this.workingDir,
mode: this.mode,
@@ -913,23 +963,8 @@ export class Session extends EventEmitter {
allowedTools: this._allowedTools,
openCodeConfig: this._openCodeConfig,
resumeSessionId: this._resumeSessionId,
});
if (!newPid) {
console.error('[Session] Failed to respawn pane, will create new session');
needsNewSession = true;
} else {
// Wait a moment for the respawned process to fully start
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
}
}
// Check if we already have a mux session (restored session)
const isRestoredSession = this._muxSession !== null && !needsNewSession;
if (isRestoredSession) {
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
} else {
// Create a new mux session
this._muxSession = await this._mux.createSession({
},
createSessionOptions: {
sessionId: this.id,
workingDir: this.workingDir,
mode: this.mode,
@@ -940,36 +975,16 @@ export class Session extends EventEmitter {
allowedTools: this._allowedTools,
openCodeConfig: this._openCodeConfig,
resumeSessionId: this._resumeSessionId,
});
console.log('[Session] Created mux session:', this._muxSession.muxName);
// No extra sleep — createSession() already waits for tmux readiness
}
},
spawnErrLabel: 'mux attachment',
});
// Attach to the mux session via PTY
try {
this.ptyProcess = pty.spawn(
this._mux.getAttachCommand(),
this._mux.getAttachArgs(this._muxSession!.muxName),
{
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: this.workingDir,
env: buildMuxAttachEnv(),
}
);
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
this._claudeSessionId = this._resumeSessionId || this.id;
} catch (spawnErr) {
console.error('[Session] Failed to spawn PTY for mux attachment:', spawnErr);
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
throw spawnErr;
}
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
this._claudeSessionId = this._resumeSessionId || this.id;
// For NEW mux sessions: wait for readiness then clean buffer
// For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch
if (!isRestoredSession) {
if (!isRestored) {
if (this.mode === 'opencode') {
// OpenCode uses Bubble Tea TUI — no ❯ prompt to detect.
// Wait for TUI to stabilize (output stops changing), then mark ready.
@@ -1056,12 +1071,7 @@ export class Session extends EventEmitter {
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '').replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L
if (!data) return; // Skip if only filtered sequences
// BufferAccumulator handles auto-trimming when max size exceeded
this._terminalBuffer.append(data);
this._lastActivityAt = Date.now();
this.emit('terminal', data);
this.emit('output', data);
this._handleTerminalOutput(data);
// === Idle/working detection runs on every chunk (latency-sensitive) ===
// Detect if Claude is working or at prompt
@@ -1257,13 +1267,7 @@ export class Session extends EventEmitter {
throw new Error('Session already has a running process');
}
this._status = 'busy';
this._terminalBuffer.clear();
this._textOutput.clear();
this._errorBuffer = '';
this._messages = [];
this._lineBuffer = '';
this._lastActivityAt = Date.now();
this._resetBuffers();
// Use user's default shell or bash
const shell = process.env.SHELL || '/bin/bash';
@@ -1275,69 +1279,26 @@ export class Session extends EventEmitter {
// If mux wrapping is enabled, create or attach to a mux session
if (this._useMux && this._mux) {
try {
// Verify stale mux session — tmux may have been destroyed externally
if (this._muxSession && !this._mux.muxSessionExists(this._muxSession.muxName)) {
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
this._muxSession = null;
}
// Check if session exists but pane is dead (remain-on-exit keeps it alive)
let needsNewSession = false;
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
const newPid = await this._mux.respawnPane({
const { isRestored } = await this._setupOrAttachMuxSession({
respawnPaneOptions: {
sessionId: this.id,
workingDir: this.workingDir,
mode: 'shell',
niceConfig: this._niceConfig,
});
if (!newPid) {
console.error('[Session] Failed to respawn pane, will create new session');
needsNewSession = true;
} else {
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
}
}
// Check if we already have a mux session (restored session)
const isRestoredSession = this._muxSession !== null && !needsNewSession;
if (isRestoredSession) {
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
} else {
// Create a new mux session
this._muxSession = await this._mux.createSession({
},
createSessionOptions: {
sessionId: this.id,
workingDir: this.workingDir,
mode: 'shell',
name: this._name,
niceConfig: this._niceConfig,
});
console.log('[Session] Created mux session:', this._muxSession.muxName);
// No extra sleep — createSession() already waits for tmux readiness
}
// Attach to the mux session via PTY
try {
this.ptyProcess = pty.spawn(
this._mux.getAttachCommand(),
this._mux.getAttachArgs(this._muxSession!.muxName),
{
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: this.workingDir,
env: buildMuxAttachEnv(),
}
);
} catch (spawnErr) {
console.error('[Session] Failed to spawn PTY for shell mux attachment:', spawnErr);
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
throw spawnErr;
}
},
spawnErrLabel: 'shell mux attachment',
});
// For NEW sessions: clear by sending 'clear' command to the shell
// For RESTORED sessions: don't clear - we want to see the existing output
if (!isRestoredSession) {
if (!isRestored) {
setTimeout(() => {
if (this.ptyProcess) {
this._terminalBuffer.clear();
@@ -1378,12 +1339,7 @@ export class Session extends EventEmitter {
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '');
if (!data) return; // Skip if only focus sequences
// BufferAccumulator handles auto-trimming when max size exceeded
this._terminalBuffer.append(data);
this._lastActivityAt = Date.now();
this.emit('terminal', data);
this.emit('output', data);
this._handleTerminalOutput(data);
});
this.ptyProcess.onExit(({ exitCode }) => {
@@ -1448,13 +1404,7 @@ export class Session extends EventEmitter {
return;
}
this._status = 'busy';
this._terminalBuffer.clear();
this._textOutput.clear();
this._errorBuffer = '';
this._messages = [];
this._lineBuffer = '';
this._lastActivityAt = Date.now();
this._resetBuffers();
this._promptResolved = false; // Reset race condition guard
this.resolvePromise = resolve;
@@ -1497,12 +1447,7 @@ export class Session extends EventEmitter {
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '');
if (!data) return; // Skip if only focus sequences
// BufferAccumulator handles auto-trimming when max size exceeded
this._terminalBuffer.append(data);
this._lastActivityAt = Date.now();
this.emit('terminal', data);
this.emit('output', data);
this._handleTerminalOutput(data);
// Also try to parse JSON lines for structured data
this.processOutput(data);
@@ -1534,9 +1479,11 @@ export class Session extends EventEmitter {
this._status = 'idle';
const cost = resultMsg.total_cost_usd || 0;
this._totalCost += cost;
this.emit('completion', resultMsg.result || '', cost);
// Claude CLI stream-json may return empty result field — fall back to accumulated text output
const result = resultMsg.result || this._textOutput.value || '';
this.emit('completion', result, cost);
if (resolve) {
resolve({ result: resultMsg.result || '', cost });
resolve({ result, cost });
}
} else if (exitCode !== 0 || (resultMsg && resultMsg.is_error)) {
this._status = 'error';
@@ -1565,6 +1512,117 @@ export class Session extends EventEmitter {
});
}
private _resetBuffers(): void {
this._status = 'busy';
this._terminalBuffer.clear();
this._textOutput.clear();
this._errorBuffer = '';
this._messages = [];
this._lineBuffer = '';
this._lastActivityAt = Date.now();
}
private _clearAllTimers(): void {
// Clear activity timeout to prevent memory leak
if (this.activityTimeout) {
clearTimeout(this.activityTimeout);
this.activityTimeout = null;
}
// Clear line buffer flush timer
if (this._lineBufferFlushTimer) {
clearTimeout(this._lineBufferFlushTimer);
this._lineBufferFlushTimer = null;
}
// Destroy auto-compact/auto-clear automation (clears its timers)
this._autoOps.destroy();
// Clear prompt check timers
if (this._promptCheckInterval) {
clearInterval(this._promptCheckInterval);
this._promptCheckInterval = null;
}
if (this._promptCheckTimeout) {
clearTimeout(this._promptCheckTimeout);
this._promptCheckTimeout = null;
}
// Clear shell idle timer
if (this._shellIdleTimer) {
clearTimeout(this._shellIdleTimer);
this._shellIdleTimer = null;
}
// Clear expensive processing timer
if (this._expensiveProcessTimer) {
clearTimeout(this._expensiveProcessTimer);
this._expensiveProcessTimer = null;
}
this._pendingCleanData = '';
}
private _handleJsonMessage(cleanLine: string, rawLine: string): void {
try {
const msg = JSON.parse(cleanLine) as ClaudeMessage;
this._messages.push(msg);
this.emit('message', msg);
// Trim messages array for long-running sessions
if (this._messages.length > MAX_MESSAGES) {
this._messages = this._messages.slice(-Math.floor(MAX_MESSAGES * 0.8));
}
// Extract Claude session ID from messages (can be in any message type)
// Support both sessionId (camelCase) and session_id (snake_case)
const msgSessionId =
((msg as unknown as Record<string, unknown>).sessionId as string | undefined) ?? msg.session_id;
if (msgSessionId && !this._claudeSessionId) {
this._claudeSessionId = msgSessionId;
}
// Process message for task tracking
this._taskTracker.processMessage(msg);
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
this._textOutput.append(block.text);
}
}
// Track tokens from usage (with validation)
if (msg.message.usage) {
const inputDelta = msg.message.usage.input_tokens || 0;
const outputDelta = msg.message.usage.output_tokens || 0;
// Sanity check: max 100k tokens per message (generous limit)
const MAX_TOKENS_PER_MESSAGE = 100_000;
if (inputDelta > 0 && inputDelta <= MAX_TOKENS_PER_MESSAGE) {
this._totalInputTokens += inputDelta;
}
if (outputDelta > 0 && outputDelta <= MAX_TOKENS_PER_MESSAGE) {
this._totalOutputTokens += outputDelta;
}
// Check if we should auto-compact or auto-clear
this._autoOps.checkAutoCompact();
this._autoOps.checkAutoClear();
}
}
if (msg.type === 'result' && msg.total_cost_usd) {
this._totalCost = msg.total_cost_usd;
}
} catch (parseErr) {
// Not JSON, just regular output - this is expected for non-JSON lines
console.debug(
'[Session] Line not JSON (expected for text output):',
parseErr instanceof Error ? parseErr.message : parseErr
);
this._textOutput.append(rawLine + '\n');
}
}
private processOutput(data: string): void {
// Early return if session is stopped to prevent any processing or timer creation
if (this._isStopped) return;
@@ -1606,64 +1664,7 @@ export class Session extends EventEmitter {
const cleanLine = trimmed.replace(ANSI_ESCAPE_PATTERN_FULL, '');
if (cleanLine.startsWith('{') && cleanLine.endsWith('}')) {
try {
const msg = JSON.parse(cleanLine) as ClaudeMessage;
this._messages.push(msg);
this.emit('message', msg);
// Trim messages array for long-running sessions
if (this._messages.length > MAX_MESSAGES) {
this._messages = this._messages.slice(-Math.floor(MAX_MESSAGES * 0.8));
}
// Extract Claude session ID from messages (can be in any message type)
// Support both sessionId (camelCase) and session_id (snake_case)
const msgSessionId =
((msg as unknown as Record<string, unknown>).sessionId as string | undefined) ?? msg.session_id;
if (msgSessionId && !this._claudeSessionId) {
this._claudeSessionId = msgSessionId;
}
// Process message for task tracking
this._taskTracker.processMessage(msg);
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
this._textOutput.append(block.text);
}
}
// Track tokens from usage (with validation)
if (msg.message.usage) {
const inputDelta = msg.message.usage.input_tokens || 0;
const outputDelta = msg.message.usage.output_tokens || 0;
// Sanity check: max 100k tokens per message (generous limit)
const MAX_TOKENS_PER_MESSAGE = 100_000;
if (inputDelta > 0 && inputDelta <= MAX_TOKENS_PER_MESSAGE) {
this._totalInputTokens += inputDelta;
}
if (outputDelta > 0 && outputDelta <= MAX_TOKENS_PER_MESSAGE) {
this._totalOutputTokens += outputDelta;
}
// Check if we should auto-compact or auto-clear
this._autoOps.checkAutoCompact();
this._autoOps.checkAutoClear();
}
}
if (msg.type === 'result' && msg.total_cost_usd) {
this._totalCost = msg.total_cost_usd;
}
} catch (parseErr) {
// Not JSON, just regular output - this is expected for non-JSON lines
console.debug(
'[Session] Line not JSON (expected for text output):',
parseErr instanceof Error ? parseErr.message : parseErr
);
this._textOutput.append(line + '\n');
}
this._handleJsonMessage(cleanLine, line);
} else if (trimmed) {
this._textOutput.append(line + '\n');
}
@@ -1955,7 +1956,7 @@ export class Session extends EventEmitter {
this._status = 'busy';
this._lastActivityAt = Date.now();
this.runPrompt(input).catch((err) => {
const errorMsg = err instanceof Error ? err.message : String(err);
const errorMsg = getErrorMessage(err);
// Clean up task state so the task queue doesn't get stuck
if (this._currentTaskId) {
const taskId = this._currentTaskId;
@@ -2030,43 +2031,7 @@ export class Session extends EventEmitter {
// Set stopped flag first to prevent new timers from being created
this._isStopped = true;
// Clear activity timeout to prevent memory leak
if (this.activityTimeout) {
clearTimeout(this.activityTimeout);
this.activityTimeout = null;
}
// Clear line buffer flush timer
if (this._lineBufferFlushTimer) {
clearTimeout(this._lineBufferFlushTimer);
this._lineBufferFlushTimer = null;
}
// Destroy auto-compact/auto-clear automation (clears its timers)
this._autoOps.destroy();
// Clear prompt check timers
if (this._promptCheckInterval) {
clearInterval(this._promptCheckInterval);
this._promptCheckInterval = null;
}
if (this._promptCheckTimeout) {
clearTimeout(this._promptCheckTimeout);
this._promptCheckTimeout = null;
}
// Clear shell idle timer
if (this._shellIdleTimer) {
clearTimeout(this._shellIdleTimer);
this._shellIdleTimer = null;
}
// Clear expensive processing timer
if (this._expensiveProcessTimer) {
clearTimeout(this._expensiveProcessTimer);
this._expensiveProcessTimer = null;
}
this._pendingCleanData = '';
this._clearAllTimers();
// Immediately cleanup Promise callbacks to prevent orphaned references
// during the rest of stop() processing (e.g., if mux kill times out)
+72 -78
View File
@@ -116,6 +116,26 @@ export class StateStore {
this.loadRalphStates();
}
private _mergeWithInitialState(parsed: Partial<AppState>): AppState {
const initial = createInitialState();
return {
...initial,
...parsed,
sessions: { ...parsed.sessions },
tasks: { ...parsed.tasks },
ralphLoop: { ...initial.ralphLoop, ...parsed.ralphLoop },
config: { ...initial.config, ...parsed.config },
};
}
private _resetCircuitBreaker(): void {
this.consecutiveSaveFailures = 0;
if (this.circuitBreakerOpen) {
console.log('[StateStore] Circuit breaker CLOSED - save succeeded');
this.circuitBreakerOpen = false;
}
}
private ensureDir(): void {
const dir = dirname(this.filePath);
if (!existsSync(dir)) {
@@ -132,15 +152,7 @@ export class StateStore {
if (existsSync(path)) {
const data = readFileSync(path, 'utf-8');
const parsed = JSON.parse(data) as Partial<AppState>;
const initial = createInitialState();
const result = {
...initial,
...parsed,
sessions: { ...parsed.sessions },
tasks: { ...parsed.tasks },
ralphLoop: { ...initial.ralphLoop, ...parsed.ralphLoop },
config: { ...initial.config, ...parsed.config },
};
const result = this._mergeWithInitialState(parsed);
if (path !== this.filePath) {
console.warn(`[StateStore] Recovered state from backup: ${path}`);
}
@@ -195,16 +207,7 @@ export class StateStore {
* Only dirty sessions are re-serialized; clean sessions use cached JSON fragments.
*/
private assembleStateJson(): string {
// Re-serialize dirty sessions and update cache
for (const id of this.dirtySessions) {
const session = this.state.sessions[id];
if (session) {
this.cachedSessionJsons.set(id, JSON.stringify(session));
} else {
this.cachedSessionJsons.delete(id);
}
}
this.dirtySessions.clear();
this.updateDirtySessionCache();
// Build sessions object from cached fragments
const sessionParts: string[] = [];
@@ -218,6 +221,25 @@ export class StateStore {
sessionParts.push(`${JSON.stringify(id)}:${json}`);
}
this.pruneStaleCacheEntries();
return this.buildPartialJson(sessionParts);
}
private updateDirtySessionCache(): void {
// Re-serialize dirty sessions and update cache
for (const id of this.dirtySessions) {
const session = this.state.sessions[id];
if (session) {
this.cachedSessionJsons.set(id, JSON.stringify(session));
} else {
this.cachedSessionJsons.delete(id);
}
}
this.dirtySessions.clear();
}
private pruneStaleCacheEntries(): void {
// Prune stale cache entries (sessions removed via direct state mutation)
if (this.cachedSessionJsons.size > Object.keys(this.state.sessions).length) {
for (const cachedId of this.cachedSessionJsons.keys()) {
@@ -226,7 +248,9 @@ export class StateStore {
}
}
}
}
private buildPartialJson(sessionParts: string[]): string {
// Build final JSON: sessions from cache, everything else re-serialized (tiny)
const sessionsJson = `{${sessionParts.join(',')}}`;
@@ -249,6 +273,28 @@ export class StateStore {
return `{${parts.join(',')}}`;
}
private serializeState(): string | null {
try {
return this.assembleStateJson();
} catch (assembleErr) {
// Fallback to full serialization if incremental assembly fails
console.warn('[StateStore] assembleStateJson failed, falling back to full serialize:', assembleErr);
this.cachedSessionJsons.clear();
this.dirtySessions.clear();
try {
return JSON.stringify(this.state);
} catch (err) {
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
this.consecutiveSaveFailures++;
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
this.circuitBreakerOpen = true;
}
return null;
}
}
}
private async _doSaveAsync(): Promise<void> {
this.saveDeb.cancel();
if (!this.dirty) {
@@ -265,28 +311,10 @@ export class StateStore {
const tempPath = this.filePath + '.tmp';
const backupPath = this.filePath + '.bak';
let json: string;
// Step 1: Serialize state (validates it's JSON-safe)
try {
json = this.assembleStateJson();
} catch (assembleErr) {
// Fallback to full serialization if incremental assembly fails
console.warn('[StateStore] assembleStateJson failed, falling back to full serialize:', assembleErr);
this.cachedSessionJsons.clear();
this.dirtySessions.clear();
try {
json = JSON.stringify(this.state);
} catch (err) {
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
this.consecutiveSaveFailures++;
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
this.circuitBreakerOpen = true;
}
return;
}
}
const json = this.serializeState();
if (json === null) return;
// Clear dirty flag BEFORE async I/O so mutations during write re-set it.
// The state snapshot is already captured in `json` above.
@@ -305,11 +333,7 @@ export class StateStore {
await writeFile(tempPath, json, 'utf-8');
await rename(tempPath, this.filePath);
this.consecutiveSaveFailures = 0;
if (this.circuitBreakerOpen) {
console.log('[StateStore] Circuit breaker CLOSED - save succeeded');
this.circuitBreakerOpen = false;
}
this._resetCircuitBreaker();
} catch (err) {
console.error('[StateStore] Failed to write state file:', err);
// Re-mark dirty so the data is retried on the next save cycle
@@ -351,27 +375,9 @@ export class StateStore {
const tempPath = this.filePath + '.tmp';
const backupPath = this.filePath + '.bak';
let json: string;
try {
json = this.assembleStateJson();
} catch (assembleErr) {
// Fallback to full serialization if incremental assembly fails
console.warn('[StateStore] assembleStateJson failed, falling back to full serialize:', assembleErr);
this.cachedSessionJsons.clear();
this.dirtySessions.clear();
try {
json = JSON.stringify(this.state);
} catch (err) {
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
this.consecutiveSaveFailures++;
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
this.circuitBreakerOpen = true;
}
return;
}
}
const json = this.serializeState();
if (json === null) return;
// Backup via atomic copy (avoids reading entire file into memory)
try {
@@ -387,11 +393,7 @@ export class StateStore {
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');
this.circuitBreakerOpen = false;
}
this._resetCircuitBreaker();
} catch (err) {
console.error('[StateStore] Failed to write state file:', err);
this.consecutiveSaveFailures++;
@@ -417,15 +419,7 @@ export class StateStore {
if (existsSync(backupPath)) {
const backupContent = readFileSync(backupPath, 'utf-8');
const parsed = JSON.parse(backupContent) as Partial<AppState>;
const initial = createInitialState();
this.state = {
...initial,
...parsed,
sessions: { ...parsed.sessions },
tasks: { ...parsed.tasks },
ralphLoop: { ...initial.ralphLoop, ...parsed.ralphLoop },
config: { ...initial.config, ...parsed.config },
};
this.state = this._mergeWithInitialState(parsed);
console.log('[StateStore] Successfully recovered state from backup');
// Reset circuit breaker after successful recovery
this.circuitBreakerOpen = false;
+253 -217
View File
@@ -222,6 +222,83 @@ export class SubagentWatcher extends EventEmitter {
return INTERNAL_AGENT_PATTERNS.some((pattern) => pattern.test(description));
}
/**
* Mark a subagent as completed: clear PID, set status, clean up pending tool calls, emit event.
*/
private markSubagentAsCompleted(info: SubagentInfo): void {
info.pid = undefined;
info.status = 'completed';
this.pendingToolCalls.delete(info.agentId);
this.emit('subagent:completed', info);
}
/**
* Extract text from message content, handling both string and array formats.
* For array content, returns the text from the first 'text' block.
*/
private extractFirstTextContent(
content: string | Array<{ type: string; text?: string }> | undefined
): string | undefined {
if (!content) return undefined;
if (typeof content === 'string') {
const trimmed = content.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
if (Array.isArray(content)) {
const firstContent = content[0];
if (firstContent?.type === 'text' && firstContent.text) {
const trimmed = firstContent.text.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
}
return undefined;
}
/**
* Process a tool_result content block: look up pending tool call, emit tool_result event.
*/
private emitToolResult(
content: { tool_use_id: string; content?: string | Array<{ type: string; text?: string }>; is_error?: boolean },
agentId: string,
sessionId: string,
timestamp: string
): void {
const resultContent = this.extractToolResultContent(content.content);
const agentPendingCalls = this.pendingToolCalls.get(agentId);
const pendingCall = agentPendingCalls?.get(content.tool_use_id);
const toolName = pendingCall?.toolName;
// Delete after lookup to prevent memory leak
agentPendingCalls?.delete(content.tool_use_id);
const toolResult: SubagentToolResult = {
agentId,
sessionId,
timestamp,
toolUseId: content.tool_use_id,
tool: toolName,
preview: resultContent.substring(0, MESSAGE_TEXT_LIMIT),
contentLength: resultContent.length,
isError: content.is_error || false,
};
this.emit('subagent:tool_result', toolResult);
}
/**
* Find the oldest inactive (non-active) agent for LRU eviction.
* Returns the agent ID of the oldest inactive agent, or null if all are active.
*/
private findOldestInactiveAgent(): string | null {
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;
}
}
return oldestId;
}
/**
* Extract short model identifier from full model name
*/
@@ -307,10 +384,7 @@ export class SubagentWatcher extends EventEmitter {
const alive = this.checkSubagentAliveFromPidMap(info, pidMap);
if (!alive) {
info.pid = undefined;
info.status = 'completed';
this.pendingToolCalls.delete(info.agentId);
this.emit('subagent:completed', info);
this.markSubagentAsCompleted(info);
}
}
}
@@ -677,10 +751,7 @@ export class SubagentWatcher extends EventEmitter {
const pid = await this.findSubagentProcess(info.sessionId);
if (pid) {
process.kill(pid, 'SIGTERM');
info.pid = undefined;
info.status = 'completed';
this.pendingToolCalls.delete(info.agentId);
this.emit('subagent:completed', info);
this.markSubagentAsCompleted(info);
return true;
}
} catch {
@@ -688,10 +759,7 @@ export class SubagentWatcher extends EventEmitter {
}
// Mark as completed even if we couldn't find the process
info.pid = undefined;
info.status = 'completed';
this.pendingToolCalls.delete(info.agentId);
this.emit('subagent:completed', info);
this.markSubagentAsCompleted(info);
return true;
}
@@ -843,19 +911,9 @@ export class SubagentWatcher extends EventEmitter {
}
} else if (entry.type === 'user' && entry.message?.content) {
// Handle both string and array content formats
if (typeof entry.message.content === 'string') {
const text = entry.message.content.trim();
if (text.length < 100 && !text.includes('{')) {
lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, USER_TEXT_PREVIEW_LENGTH)}`);
}
} else {
const firstContent = entry.message.content[0];
if (firstContent?.type === 'text' && firstContent.text) {
const text = firstContent.text.trim();
if (text.length < 100 && !text.includes('{')) {
lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, USER_TEXT_PREVIEW_LENGTH)}`);
}
}
const text = this.extractFirstTextContent(entry.message.content);
if (text && text.length < 100 && !text.includes('{')) {
lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, USER_TEXT_PREVIEW_LENGTH)}`);
}
}
}
@@ -930,6 +988,24 @@ export class SubagentWatcher extends EventEmitter {
return truncated.replace(/[.!?,:\s]+$/, '');
}
private async _resolveDescription(
projectHash: string,
sessionId: string,
agentId: string,
filePath: string,
fallbackText?: string
): Promise<string | undefined> {
// First try parent transcript (most reliable)
const fromParent = await this.extractDescriptionFromParentTranscript(projectHash, sessionId, agentId);
if (fromParent) return fromParent;
// Fallback: inline text (from processEntry) or file extraction
if (fallbackText) {
return this.extractSmartTitle(fallbackText);
}
return this.extractDescriptionFromFile(filePath);
}
/**
* Extract the short description from the parent session's transcript.
* This is the most reliable method because it reads the actual Task tool result
@@ -1027,15 +1103,7 @@ export class SubagentWatcher extends EventEmitter {
try {
const entry = JSON.parse(line);
if (entry.type === 'user' && entry.message?.content) {
let text: string | undefined;
if (typeof entry.message.content === 'string') {
text = entry.message.content.trim();
} else if (Array.isArray(entry.message.content)) {
const firstContent = entry.message.content[0];
if (firstContent?.type === 'text' && firstContent.text) {
text = firstContent.text.trim();
}
}
const text = this.extractFirstTextContent(entry.message.content);
if (text) {
resolved = true;
rl.close();
@@ -1195,18 +1263,13 @@ export class SubagentWatcher extends EventEmitter {
// Retry description extraction if missing (race condition fix)
if (!existingInfo.description) {
// First try parent transcript (most reliable)
let extractedDescription = await this.extractDescriptionFromParentTranscript(
const extractedDescription = await this._resolveDescription(
existingInfo.projectHash,
existingInfo.sessionId,
agentId
agentId,
filePath
);
// Fallback to subagent file
if (!extractedDescription) {
extractedDescription = await this.extractDescriptionFromFile(filePath);
}
if (extractedDescription) {
// Check if this is an internal agent - if so, remove it
if (this.isInternalAgent(extractedDescription)) {
this.removeAgent(agentId);
return;
@@ -1257,13 +1320,7 @@ export class SubagentWatcher extends EventEmitter {
}
// Extract description - prefer reading from parent transcript (most reliable)
// The parent transcript has the exact Task tool call with description parameter
let description = await this.extractDescriptionFromParentTranscript(projectHash, sessionId, agentId);
// Fallback: extract a smart title from the subagent's prompt if parent lookup failed
if (!description) {
description = await this.extractDescriptionFromFile(filePath);
}
const description = await this._resolveDescription(projectHash, sessionId, agentId, filePath);
// Skip internal Claude Code agents (e.g., suggestion mode) - not real subagents
if (this.isInternalAgent(description)) {
@@ -1286,14 +1343,7 @@ export class SubagentWatcher extends EventEmitter {
// 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;
}
}
const oldestId = this.findOldestInactiveAgent();
if (oldestId) {
this.removeAgent(oldestId);
}
@@ -1363,51 +1413,11 @@ export class SubagentWatcher extends EventEmitter {
private async processEntry(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): Promise<void> {
const info = this.agentInfo.get(agentId);
// Extract model from assistant messages (first one sets the model)
if (info && entry.type === 'assistant' && entry.message?.model && !info.model) {
info.model = entry.message.model;
info.modelShort = this.extractModelShort(entry.message.model);
this.emit('subagent:updated', info);
}
if (info) {
this._processModelInfo(entry, info);
this._processTokenInfo(entry, info);
// Aggregate token usage from messages
if (info && entry.message?.usage) {
if (entry.message.usage.input_tokens) {
info.totalInputTokens = (info.totalInputTokens || 0) + entry.message.usage.input_tokens;
}
if (entry.message.usage.output_tokens) {
info.totalOutputTokens = (info.totalOutputTokens || 0) + entry.message.usage.output_tokens;
}
}
// Check if this is first user message and description is missing
if (info && !info.description && entry.type === 'user' && entry.message?.content) {
// First try parent transcript (most reliable)
let description = await this.extractDescriptionFromParentTranscript(info.projectHash, info.sessionId, agentId);
// Fallback: extract smart title from the prompt content
if (!description) {
let text: string | undefined;
if (typeof entry.message.content === 'string') {
text = entry.message.content.trim();
} else if (Array.isArray(entry.message.content)) {
const firstContent = entry.message.content[0];
if (firstContent?.type === 'text' && firstContent.text) {
text = firstContent.text.trim();
}
}
if (text) {
description = this.extractSmartTitle(text);
}
}
if (description) {
// Check if this is an internal agent - if so, remove it
if (this.isInternalAgent(description)) {
this.removeAgent(agentId);
return;
}
info.description = description;
this.emit('subagent:updated', info);
}
if (await this._processDescription(entry, agentId, info)) return;
}
if (entry.type === 'progress' && entry.data) {
@@ -1418,7 +1428,6 @@ export class SubagentWatcher extends EventEmitter {
progressType: entry.data.type,
query: entry.data.query,
resultCount: entry.data.resultCount,
// Extract hook event info if present
hookEvent: entry.data.hookEvent,
hookName:
entry.data.hookName ||
@@ -1428,139 +1437,166 @@ export class SubagentWatcher extends EventEmitter {
};
this.emit('subagent:progress', progress);
} else if (entry.type === 'assistant' && entry.message?.content) {
// Handle both string and array content formats
if (typeof entry.message.content === 'string') {
const text = entry.message.content.trim();
if (text.length > 0) {
const message: SubagentMessage = {
this._processAssistantContent(entry, agentId, sessionId);
} else if (entry.type === 'user' && entry.message?.content) {
this._processUserContent(entry, agentId, sessionId);
}
}
private _processModelInfo(entry: SubagentTranscriptEntry, agent: SubagentInfo): void {
if (entry.type === 'assistant' && entry.message?.model && !agent.model) {
agent.model = entry.message.model;
agent.modelShort = this.extractModelShort(entry.message.model);
this.emit('subagent:updated', agent);
}
}
private _processTokenInfo(entry: SubagentTranscriptEntry, agent: SubagentInfo): void {
if (!entry.message?.usage) return;
if (entry.message.usage.input_tokens) {
agent.totalInputTokens = (agent.totalInputTokens || 0) + entry.message.usage.input_tokens;
}
if (entry.message.usage.output_tokens) {
agent.totalOutputTokens = (agent.totalOutputTokens || 0) + entry.message.usage.output_tokens;
}
}
private async _processDescription(
entry: SubagentTranscriptEntry,
agentId: string,
agent: SubagentInfo
): Promise<boolean> {
if (agent.description || entry.type !== 'user' || !entry.message?.content) return false;
const fallbackText = this.extractFirstTextContent(entry.message.content);
const description = await this._resolveDescription(
agent.projectHash,
agent.sessionId,
agentId,
agent.filePath,
fallbackText
);
if (description) {
if (this.isInternalAgent(description)) {
this.removeAgent(agentId);
return true;
}
agent.description = description;
this.emit('subagent:updated', agent);
}
return false;
}
private _processAssistantContent(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): void {
const messageContent = entry.message!.content;
if (typeof messageContent === 'string') {
const text = messageContent.trim();
if (text.length > 0) {
const message: SubagentMessage = {
agentId,
sessionId,
timestamp: entry.timestamp,
role: 'assistant',
text: text.substring(0, MESSAGE_TEXT_LIMIT),
};
this.emit('subagent:message', message);
}
} else {
for (const content of messageContent) {
if (content.type === 'tool_use' && content.name) {
// Store toolUseId for linking to results, with timestamp for TTL cleanup
if (content.id) {
if (!this.pendingToolCalls.has(agentId)) {
this.pendingToolCalls.set(agentId, new Map());
}
const agentCalls = this.pendingToolCalls.get(agentId)!;
// Enforce size limit to prevent memory leak from rapid tool calls
if (agentCalls.size >= MAX_PENDING_TOOL_CALLS) {
// FIFO eviction: delete first (oldest) entry using Map insertion order
const firstKey = agentCalls.keys().next().value;
if (firstKey !== undefined) agentCalls.delete(firstKey);
}
agentCalls.set(content.id, {
toolName: content.name,
timestamp: Date.now(),
});
}
const toolCall: SubagentToolCall = {
agentId,
sessionId,
timestamp: entry.timestamp,
role: 'assistant',
text: text.substring(0, MESSAGE_TEXT_LIMIT),
tool: content.name,
input: this.getTruncatedInput(content.name, content.input || {}),
toolUseId: content.id,
fullInput: content.input || {},
};
this.emit('subagent:message', message);
}
} else {
for (const content of entry.message.content) {
if (content.type === 'tool_use' && content.name) {
// Store toolUseId for linking to results, with timestamp for TTL cleanup
if (content.id) {
if (!this.pendingToolCalls.has(agentId)) {
this.pendingToolCalls.set(agentId, new Map());
}
const agentCalls = this.pendingToolCalls.get(agentId)!;
// Enforce size limit to prevent memory leak from rapid tool calls
if (agentCalls.size >= MAX_PENDING_TOOL_CALLS) {
// FIFO eviction: delete first (oldest) entry using Map insertion order
const firstKey = agentCalls.keys().next().value;
if (firstKey !== undefined) agentCalls.delete(firstKey);
}
agentCalls.set(content.id, {
toolName: content.name,
timestamp: Date.now(),
});
}
this.emit('subagent:tool_call', toolCall);
const toolCall: SubagentToolCall = {
// Update tool call count
const agentInfo = this.agentInfo.get(agentId);
if (agentInfo) {
agentInfo.toolCallCount++;
}
} else if (content.type === 'tool_result' && content.tool_use_id) {
this.emitToolResult(
{ tool_use_id: content.tool_use_id, content: content.content, is_error: content.is_error },
agentId,
sessionId,
entry.timestamp
);
} else if (content.type === 'text' && content.text) {
const text = content.text.trim();
if (text.length > 0) {
const message: SubagentMessage = {
agentId,
sessionId,
timestamp: entry.timestamp,
tool: content.name,
input: this.getTruncatedInput(content.name, content.input || {}),
toolUseId: content.id,
fullInput: content.input || {},
role: 'assistant',
text: text.substring(0, MESSAGE_TEXT_LIMIT),
};
this.emit('subagent:tool_call', toolCall);
// Update tool call count
const agentInfo = this.agentInfo.get(agentId);
if (agentInfo) {
agentInfo.toolCallCount++;
}
} else if (content.type === 'tool_result' && content.tool_use_id) {
// Extract tool result
const resultContent = this.extractToolResultContent(content.content);
const agentPendingCalls = this.pendingToolCalls.get(agentId);
const pendingCall = agentPendingCalls?.get(content.tool_use_id);
const toolName = pendingCall?.toolName;
// Delete after lookup to prevent memory leak
agentPendingCalls?.delete(content.tool_use_id);
const toolResult: SubagentToolResult = {
agentId,
sessionId,
timestamp: entry.timestamp,
toolUseId: content.tool_use_id,
tool: toolName,
preview: resultContent.substring(0, MESSAGE_TEXT_LIMIT),
contentLength: resultContent.length,
isError: content.is_error || false,
};
this.emit('subagent:tool_result', toolResult);
} else if (content.type === 'text' && content.text) {
const text = content.text.trim();
if (text.length > 0) {
const message: SubagentMessage = {
agentId,
sessionId,
timestamp: entry.timestamp,
role: 'assistant',
text: text.substring(0, MESSAGE_TEXT_LIMIT), // Limit text length
};
this.emit('subagent:message', message);
}
this.emit('subagent:message', message);
}
}
}
} else if (entry.type === 'user' && entry.message?.content) {
// Handle both string and array content formats - also check for tool_result in user messages
if (typeof entry.message.content === 'string') {
const userText = entry.message.content.trim();
if (userText.length > 0 && userText.length < 500) {
const message: SubagentMessage = {
}
}
private _processUserContent(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): void {
const messageContent = entry.message!.content;
if (typeof messageContent === 'string') {
const userText = messageContent.trim();
if (userText.length > 0 && userText.length < 500) {
const message: SubagentMessage = {
agentId,
sessionId,
timestamp: entry.timestamp,
role: 'user',
text: userText,
};
this.emit('subagent:message', message);
}
} else {
// Check for tool_result blocks in user messages (common pattern)
for (const content of messageContent) {
if (content.type === 'tool_result' && content.tool_use_id) {
this.emitToolResult(
{ tool_use_id: content.tool_use_id, content: content.content, is_error: content.is_error },
agentId,
sessionId,
timestamp: entry.timestamp,
role: 'user',
text: userText,
};
this.emit('subagent:message', message);
}
} else {
// Check for tool_result blocks in user messages (common pattern)
for (const content of entry.message.content) {
if (content.type === 'tool_result' && content.tool_use_id) {
const resultContent = this.extractToolResultContent(content.content);
const agentPendingCalls = this.pendingToolCalls.get(agentId);
const pendingCall = agentPendingCalls?.get(content.tool_use_id);
const toolName = pendingCall?.toolName;
// Delete after lookup to prevent memory leak
agentPendingCalls?.delete(content.tool_use_id);
const toolResult: SubagentToolResult = {
entry.timestamp
);
} else if (content.type === 'text' && content.text) {
const userText = content.text.trim();
if (userText.length > 0 && userText.length < 500) {
const message: SubagentMessage = {
agentId,
sessionId,
timestamp: entry.timestamp,
toolUseId: content.tool_use_id,
tool: toolName,
preview: resultContent.substring(0, MESSAGE_TEXT_LIMIT),
contentLength: resultContent.length,
isError: content.is_error || false,
role: 'user',
text: userText,
};
this.emit('subagent:tool_result', toolResult);
} else if (content.type === 'text' && content.text) {
const userText = content.text.trim();
if (userText.length > 0 && userText.length < 500) {
const message: SubagentMessage = {
agentId,
sessionId,
timestamp: entry.timestamp,
role: 'user',
text: userText,
};
this.emit('subagent:message', message);
}
this.emit('subagent:message', message);
}
}
}
+61 -65
View File
@@ -98,6 +98,9 @@ const LEGACY_MUX_NAME_PATTERN = /^claudeman-[a-f0-9-]+$/;
/** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */
const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/;
/** Characters unsafe in paths — shell metacharacters, quotes, and control chars */
const UNSAFE_PATH_CHARS = /[;&|$`(){}<>'"\n\r]/;
/**
* Validates that a session name contains only safe characters.
* Prevents command injection via malformed session IDs.
@@ -111,23 +114,7 @@ function isValidMuxName(name: string): boolean {
* Prevents command injection via malformed paths.
*/
function isValidPath(path: string): boolean {
if (
path.includes(';') ||
path.includes('&') ||
path.includes('|') ||
path.includes('$') ||
path.includes('`') ||
path.includes('(') ||
path.includes(')') ||
path.includes('{') ||
path.includes('}') ||
path.includes('<') ||
path.includes('>') ||
path.includes("'") ||
path.includes('"') ||
path.includes('\n') ||
path.includes('\r')
) {
if (UNSAFE_PATH_CHARS.test(path)) {
return false;
}
if (path.includes('..')) {
@@ -371,6 +358,52 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
/**
* Build the array of environment export commands shared by createSession() and respawnPane().
* Includes locale, mux markers, session identity, and API URL.
*/
private buildEnvExports(sessionId: string, muxName: string, mode: SessionMode): string[] {
const exports = [
'export LANG=en_US.UTF-8',
'export LC_ALL=en_US.UTF-8',
'unset COLORTERM',
'export CODEMAN_MUX=1',
`export CODEMAN_SESSION_ID=${sessionId}`,
`export CODEMAN_MUX_NAME=${muxName}`,
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
];
// Only unset CLAUDECODE for Claude sessions
if (mode === 'claude') exports.splice(2, 0, 'unset CLAUDECODE');
return exports;
}
/**
* Resolve the CLI binary directory and return the PATH export prefix string.
* Returns '' if no override is needed (shell mode) or the binary dir is not found.
* In createSession(), a missing binary dir throws — the caller handles that separately.
*/
private buildPathExport(mode: SessionMode): { pathExport: string; dir: string | null } {
if (mode === 'claude') {
const dir = findClaudeDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
if (mode === 'opencode') {
const dir = resolveOpenCodeDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
return { pathExport: '', dir: null };
}
/**
* Configure OpenCode-specific environment on a tmux session.
* Sets sensitive API keys and config content via tmux setenv
* (not visible in ps output or tmux history, inherited by panes).
*/
private _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig): void {
setOpenCodeEnvVars(muxName);
setOpenCodeConfigContent(muxName, openCodeConfig);
}
/**
* Creates a new tmux session wrapping Claude CLI or a shell.
* In test mode: creates an in-memory session only (no real tmux session).
@@ -415,33 +448,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
// Resolve CLI binary directory based on mode
let pathExport = '';
if (mode === 'claude') {
const claudeDir = findClaudeDir();
if (!claudeDir) {
throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash');
}
pathExport = `export PATH="${claudeDir}:$PATH" && `;
} else if (mode === 'opencode') {
const openCodeDir = resolveOpenCodeDir();
if (!openCodeDir) {
throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
}
pathExport = `export PATH="${openCodeDir}:$PATH" && `;
const { pathExport, dir: cliDir } = this.buildPathExport(mode);
if (mode === 'claude' && !cliDir) {
throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash');
}
if (mode === 'opencode' && !cliDir) {
throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
}
const envExports = [
'export LANG=en_US.UTF-8',
'export LC_ALL=en_US.UTF-8',
'unset COLORTERM',
'export CODEMAN_MUX=1',
`export CODEMAN_SESSION_ID=${sessionId}`,
`export CODEMAN_MUX_NAME=${muxName}`,
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
];
// Only unset CLAUDECODE for Claude sessions
if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE');
const envExportsStr = envExports.join(' && ');
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
const baseCmd = buildSpawnCommand({
mode,
@@ -489,8 +504,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// For OpenCode: set sensitive env vars and config via tmux setenv
// (not visible in ps output or tmux history, inherited by panes)
if (mode === 'opencode') {
setOpenCodeEnvVars(muxName);
setOpenCodeConfigContent(muxName, openCodeConfig);
this._configureOpenCode(muxName, openCodeConfig);
}
// Replace the shell with the actual command (no echo in terminal)
@@ -641,26 +655,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null;
// Resolve CLI binary directory based on mode
let pathExport = '';
if (mode === 'claude') {
const claudeDir = findClaudeDir();
pathExport = claudeDir ? `export PATH="${claudeDir}:$PATH" && ` : '';
} else if (mode === 'opencode') {
const openCodeDir = resolveOpenCodeDir();
pathExport = openCodeDir ? `export PATH="${openCodeDir}:$PATH" && ` : '';
}
const { pathExport } = this.buildPathExport(mode);
const envExports = [
'export LANG=en_US.UTF-8',
'export LC_ALL=en_US.UTF-8',
'unset COLORTERM',
'export CODEMAN_MUX=1',
`export CODEMAN_SESSION_ID=${sessionId}`,
`export CODEMAN_MUX_NAME=${muxName}`,
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
];
if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE');
const envExportsStr = envExports.join(' && ');
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
const baseCmd = buildSpawnCommand({
mode,
@@ -678,8 +675,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
try {
// For OpenCode: set sensitive env vars via tmux setenv before respawn
if (mode === 'opencode') {
setOpenCodeEnvVars(muxName);
setOpenCodeConfigContent(muxName, openCodeConfig);
this._configureOpenCode(muxName, openCodeConfig);
}
await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
+2 -1
View File
@@ -29,6 +29,7 @@ import {
RESTART_DELAY_MS,
FORCE_KILL_MS,
} from './config/tunnel-config.js';
import { getErrorMessage } from './types.js';
// ========== Types ==========
@@ -164,7 +165,7 @@ export class TunnelManager extends EventEmitter {
detached: false,
});
} catch (err) {
this.emit('error', `Failed to spawn cloudflared: ${err instanceof Error ? err.message : String(err)}`);
this.emit('error', `Failed to spawn cloudflared: ${getErrorMessage(err)}`);
return;
}
+138 -173
View File
@@ -432,6 +432,25 @@ class CodemanApp {
return this._elemCache[id];
}
// Clear a named timeout property: if (this[name]) { clearTimeout(this[name]); this[name] = null; }
_clearTimer(timerName) {
if (this[timerName]) {
clearTimeout(this[timerName]);
this[timerName] = null;
}
}
// Check if a selectSession generation is stale (a newer tab switch has started).
// If stale, cleans up buffer-loading state and returns true.
_isStaleSelect(selectGen) {
if (selectGen !== this._selectGeneration) {
if (this._isLoadingBuffer) this._finishBufferLoad();
this._restoringFlushedState = false;
return true;
}
return false;
}
// Format token count: 1000k -> 1m, 1450k -> 1.45m, 500 -> 500
formatTokens(count) {
if (count >= 1000000) {
@@ -588,73 +607,43 @@ class CodemanApp {
// ═══════════════════════════════════════════════════════════════
setupEventListeners() {
// Keyboard shortcut lookup table — data-driven to avoid 12 separate if-blocks.
// Each entry: { key, altKey? (alternative key match), ctrl? (require Ctrl/Cmd),
// shift? (require Shift), action }.
const SHORTCUTS = [
{ key: '?', altKey: '/', ctrl: true, action: () => this.showHelp() },
{ key: 'Enter', ctrl: true, action: () => this.quickStart() },
{ key: 'w', ctrl: true, action: () => this.killActiveSession() },
{ key: 'Tab', ctrl: true, action: () => this.nextSession() },
{ key: 'k', ctrl: true, action: () => this.killAllSessions() },
{ key: 'l', ctrl: true, action: () => this.clearTerminal() },
{ key: 'R', ctrl: true, shift: true, action: () => this.restoreTerminalSize() },
{ key: '=', altKey: '+', ctrl: true, action: () => this.increaseFontSize() },
{ key: '-', ctrl: true, action: () => this.decreaseFontSize() },
{ key: 'V', ctrl: true, shift: true, action: () => VoiceInput.toggle() },
];
// Use capture to handle before terminal
document.addEventListener('keydown', (e) => {
// Don't intercept keys during CJK IME composition
if (e.isComposing || e.keyCode === 229) return;
// Escape - close panels and modals
// Escape - close panels and modals (different logic: no preventDefault, no return)
if (e.key === 'Escape') {
this.closeAllPanels();
this.closeHelp();
}
// Ctrl/Cmd + ? - help
if ((e.ctrlKey || e.metaKey) && (e.key === '?' || e.key === '/')) {
e.preventDefault();
this.showHelp();
}
// Ctrl/Cmd + Enter - quick start
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
this.quickStart();
}
// Ctrl/Cmd + W - close active session
if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
e.preventDefault();
this.killActiveSession();
}
// Ctrl/Cmd + Tab - next session
if ((e.ctrlKey || e.metaKey) && e.key === 'Tab') {
e.preventDefault();
this.nextSession();
}
// Ctrl/Cmd + K - kill all
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
this.killAllSessions();
}
// Ctrl/Cmd + L - clear terminal
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
e.preventDefault();
this.clearTerminal();
}
// Ctrl/Cmd + Shift + R - restore terminal size (after mobile squeeze)
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'R') {
e.preventDefault();
this.restoreTerminalSize();
}
// Ctrl/Cmd + +/- - font size
if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+')) {
e.preventDefault();
this.increaseFontSize();
}
if ((e.ctrlKey || e.metaKey) && e.key === '-') {
e.preventDefault();
this.decreaseFontSize();
}
// Ctrl/Cmd + Shift + V - toggle voice input
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'V') {
e.preventDefault();
VoiceInput.toggle();
// Match against shortcut table
for (const s of SHORTCUTS) {
const keyMatch = e.key === s.key || (s.altKey && e.key === s.altKey);
const ctrlMatch = s.ctrl ? (e.ctrlKey || e.metaKey) : true;
const shiftMatch = s.shift ? e.shiftKey : !e.shiftKey;
if (keyMatch && ctrlMatch && shiftMatch) {
e.preventDefault();
s.action();
return;
}
}
}, true); // Use capture phase to handle before terminal
@@ -682,10 +671,7 @@ class CodemanApp {
}
// Clear any pending reconnect timeout to prevent duplicate connections
if (this.sseReconnectTimeout) {
clearTimeout(this.sseReconnectTimeout);
this.sseReconnectTimeout = null;
}
this._clearTimer('sseReconnectTimeout');
// Clean up existing SSE listeners before creating new connection (prevents listener accumulation)
if (this._sseListenerCleanup) {
@@ -742,9 +728,7 @@ class CodemanApp {
this.eventSource = null;
}
// Clear any existing reconnect timeout before setting new one (prevents orphaned timeouts)
if (this.sseReconnectTimeout) {
clearTimeout(this.sseReconnectTimeout);
}
this._clearTimer('sseReconnectTimeout');
// Exponential backoff: 200ms, 500ms, 1s, 2s, 4s, ... up to 30s
// Fast first retry (200ms) for server-restart case (COM deploy),
// then ramp up for real network issues.
@@ -955,15 +939,7 @@ class CodemanApp {
if (data.id === this.activeSessionId) {
this.terminal.writeln(`\x1b[1;31m Error: ${data.error}\x1b[0m`);
}
const session = this.sessions.get(data.id);
this.notificationManager?.notify({
urgency: 'critical',
category: 'session-error',
sessionId: data.id,
sessionName: session?.name || this.getShortId(data.id),
title: 'Session Error',
message: data.error || 'Unknown error',
});
this._notifySession(data.id, 'critical', 'session-error', 'Session Error', data.error || 'Unknown error');
}
_onSessionExit(data) {
@@ -976,14 +952,7 @@ class CodemanApp {
}
// Notify on unexpected exit (non-zero code)
if (data.code && data.code !== 0) {
this.notificationManager?.notify({
urgency: 'critical',
category: 'session-crash',
sessionId: data.id,
sessionName: session?.name || this.getShortId(data.id),
title: 'Session Crashed',
message: `Exited with code ${data.code}`,
});
this._notifySession(data.id, 'critical', 'session-crash', 'Session Crashed', `Exited with code ${data.code}`);
}
}
@@ -1000,15 +969,7 @@ class CodemanApp {
const threshold = this.notificationManager?.preferences?.stuckThresholdMs || 600000;
clearTimeout(this.idleTimers.get(data.id));
this.idleTimers.set(data.id, setTimeout(() => {
const s = this.sessions.get(data.id);
this.notificationManager?.notify({
urgency: 'warning',
category: 'session-stuck',
sessionId: data.id,
sessionName: s?.name || this.getShortId(data.id),
title: 'Session Idle',
message: `Idle for ${Math.round(threshold / 60000)}+ minutes`,
});
this._notifySession(data.id, 'warning', 'session-stuck', 'Session Idle', `Idle for ${Math.round(threshold / 60000)}+ minutes`);
this.idleTimers.delete(data.id);
}, threshold));
}
@@ -1039,15 +1000,7 @@ class CodemanApp {
this.showToast(`Auto-cleared at ${data.tokens.toLocaleString()} tokens`, 'info');
this.updateRespawnTokens(0);
}
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'info',
category: 'auto-clear',
sessionId: data.sessionId,
sessionName: session?.name || this.getShortId(data.sessionId),
title: 'Auto-Cleared',
message: `Context reset at ${(data.tokens || 0).toLocaleString()} tokens`,
});
this._notifySession(data.sessionId, 'info', 'auto-clear', 'Auto-Cleared', `Context reset at ${(data.tokens || 0).toLocaleString()} tokens`);
}
_onSessionCliInfo(data) {
@@ -1168,10 +1121,7 @@ class CodemanApp {
/** Close the active WebSocket connection (if any). */
_disconnectWs() {
if (this._wsReconnectTimer) {
clearTimeout(this._wsReconnectTimer);
this._wsReconnectTimer = null;
}
this._clearTimer('_wsReconnectTimer');
this._wsReconnectAttempts = 0;
if (this._ws) {
this._ws.onclose = null; // Prevent re-entrant cleanup
@@ -1317,35 +1267,12 @@ class CodemanApp {
if (!showCjk) window.cjkActive = false;
}
handleInit(data) {
// Clear the init fallback timer since we got data
if (this._initFallbackTimer) {
clearTimeout(this._initFallbackTimer);
this._initFallbackTimer = null;
}
const gen = ++this._initGeneration;
// CJK input form: controlled by user setting (with server env as override)
this._serverCjkOverride = data.inputCjkForm || false;
this._updateCjkInputState();
// Update version displays (header and toolbar)
if (data.version) {
const versionEl = this.$('versionDisplay');
const headerVersionEl = this.$('headerVersion');
if (versionEl) {
versionEl.textContent = `v${data.version}`;
versionEl.title = `Codeman v${data.version}`;
}
if (headerVersionEl) {
headerVersionEl.textContent = `v${data.version}`;
headerVersionEl.title = `Codeman v${data.version}`;
}
}
// Stop any active voice recording on reconnect
VoiceInput.cleanup();
/**
* Reset all app state maps, timers, and handlers to a clean baseline.
* Called by handleInit() on SSE reconnect / page reload to prevent
* memory leaks and stale data.
*/
_resetAllAppState() {
this.sessions.clear();
this.ralphStates.clear();
this.terminalBuffers.clear();
@@ -1359,17 +1286,11 @@ class CodemanApp {
}
this.idleTimers.clear();
// Clear flicker filter state
if (this.flickerFilterTimeout) {
clearTimeout(this.flickerFilterTimeout);
this.flickerFilterTimeout = null;
}
this._clearTimer('flickerFilterTimeout');
this.flickerFilterBuffer = '';
this.flickerFilterActive = false;
// Clear pending terminal writes
if (this.syncWaitTimeout) {
clearTimeout(this.syncWaitTimeout);
this.syncWaitTimeout = null;
}
this._clearTimer('syncWaitTimeout');
this.pendingWrites = [];
this.writeFrameScheduled = false;
this._isLoadingBuffer = false;
@@ -1428,6 +1349,36 @@ class CodemanApp {
clearInterval(this.runSummaryAutoRefreshTimer);
this.runSummaryAutoRefreshTimer = null;
}
}
handleInit(data) {
// Clear the init fallback timer since we got data
this._clearTimer('_initFallbackTimer');
const gen = ++this._initGeneration;
// CJK input form: controlled by user setting (with server env as override)
this._serverCjkOverride = data.inputCjkForm || false;
this._updateCjkInputState();
// Update version displays (header and toolbar)
if (data.version) {
const versionEl = this.$('versionDisplay');
const headerVersionEl = this.$('headerVersion');
if (versionEl) {
versionEl.textContent = `v${data.version}`;
versionEl.title = `Codeman v${data.version}`;
}
if (headerVersionEl) {
headerVersionEl.textContent = `v${data.version}`;
headerVersionEl.title = `Codeman v${data.version}`;
}
}
// Stop any active voice recording on reconnect
VoiceInput.cleanup();
this._resetAllAppState();
data.sessions.forEach(s => {
this.sessions.set(s.id, s);
// Load ralph state from session data (only if not explicitly closed by user)
@@ -2001,23 +1952,25 @@ class CodemanApp {
return this.getShortId(session.id);
}
async selectSession(sessionId) {
if (this.activeSessionId === sessionId) return;
// Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors
// programmatic focus() within the user-gesture call stack (e.g. tab click).
// After the first await the gesture context is lost and focus() is silently
// ignored, leaving the keyboard unable to send input to the terminal.
if (this.terminal) this.terminal.focus();
const _selStart = performance.now();
const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8);
_crashDiag.log(`SELECT: ${_selName}`);
console.log(`[CRASH-DIAG] selectSession START: ${sessionId.slice(0,8)}`);
const selectGen = ++this._selectGeneration;
if (selectGen !== this._selectGeneration) return; // newer tab switch won
_notifySession(sessionId, urgency, category, title, message) {
const session = this.sessions.get(sessionId);
this.notificationManager?.notify({
urgency,
category,
sessionId,
sessionName: session?.name || this.getShortId(sessionId),
title,
message,
});
}
/**
* Clean up state from the previous session before switching tabs.
* Handles: WebSocket teardown, CJK clear, flicker filter, tab completion,
* terminal write queue, IME composition, and local echo flush.
* @param {string} newSessionId - The session being switched TO.
*/
_cleanupPreviousSession(newSessionId) {
// Close WebSocket for previous session (new one opens after buffer load)
this._disconnectWs();
@@ -2026,10 +1979,7 @@ class CodemanApp {
if (cjkEl) cjkEl.value = '';
// Clean up flicker filter state when switching sessions
if (this.flickerFilterTimeout) {
clearTimeout(this.flickerFilterTimeout);
this.flickerFilterTimeout = null;
}
this._clearTimer('flickerFilterTimeout');
this.flickerFilterBuffer = '';
this.flickerFilterActive = false;
@@ -2037,14 +1987,11 @@ class CodemanApp {
this._tabCompletionSessionId = null;
this._tabCompletionRetries = 0;
this._tabCompletionBaseText = null;
if (this._tabCompletionFallback) { clearTimeout(this._tabCompletionFallback); this._tabCompletionFallback = null; }
if (this._clientDropRecoveryTimer) { clearTimeout(this._clientDropRecoveryTimer); this._clientDropRecoveryTimer = null; }
this._clearTimer('_tabCompletionFallback');
this._clearTimer('_clientDropRecoveryTimer');
// Clean up pending terminal writes to prevent old session data from appearing in new session
if (this.syncWaitTimeout) {
clearTimeout(this.syncWaitTimeout);
this.syncWaitTimeout = null;
}
this._clearTimer('syncWaitTimeout');
this.pendingWrites = [];
this.writeFrameScheduled = false;
this._isLoadingBuffer = false;
@@ -2094,9 +2041,29 @@ class CodemanApp {
// Only sessions with prior flushed text (from tab-switch-away) need detection.
// After the user's first Enter, clear() resets _bufferDetectDone = false,
// re-enabling detection for tab completion and other legitimate cases.
if (this._localEchoOverlay && !this._flushedOffsets?.has(sessionId)) {
if (this._localEchoOverlay && !this._flushedOffsets?.has(newSessionId)) {
this._localEchoOverlay.suppressBufferDetection();
}
}
async selectSession(sessionId) {
if (this.activeSessionId === sessionId) return;
// Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors
// programmatic focus() within the user-gesture call stack (e.g. tab click).
// After the first await the gesture context is lost and focus() is silently
// ignored, leaving the keyboard unable to send input to the terminal.
if (this.terminal) this.terminal.focus();
const _selStart = performance.now();
const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8);
_crashDiag.log(`SELECT: ${_selName}`);
console.log(`[CRASH-DIAG] selectSession START: ${sessionId.slice(0,8)}`);
const selectGen = ++this._selectGeneration;
if (selectGen !== this._selectGeneration) return; // newer tab switch won
this._cleanupPreviousSession(sessionId);
this.activeSessionId = sessionId;
try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
this.hideWelcome();
@@ -2186,7 +2153,7 @@ class CodemanApp {
this.terminal.clear();
this.terminal.reset();
await this.chunkedTerminalWrite(cachedBuffer);
if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; }
if (this._isStaleSelect(selectGen)) return;
this.terminal.scrollToBottom();
_crashDiag.log('CACHE_DONE');
} else if (sessionIsBusy) {
@@ -2198,7 +2165,7 @@ class CodemanApp {
_crashDiag.log('FETCH_START');
const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; }
if (this._isStaleSelect(selectGen)) return;
const data = await res.json();
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
@@ -2217,7 +2184,7 @@ class CodemanApp {
}
// Use chunked write for large buffers to avoid UI jank
await this.chunkedTerminalWrite(data.terminalBuffer);
if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; }
if (this._isStaleSelect(selectGen)) return;
// Ensure terminal is scrolled to bottom after buffer load
this.terminal.scrollToBottom();
}
@@ -2624,9 +2591,7 @@ class CodemanApp {
updateTokens() {
// Debounce at 200ms — token display is non-critical and shouldn't
// compete with input handling on the main thread
if (this._updateTokensTimeout) {
clearTimeout(this._updateTokensTimeout);
}
this._clearTimer('_updateTokensTimeout');
this._updateTokensTimeout = setTimeout(() => {
this._updateTokensTimeout = null;
this._updateTokensImmediate();
+13 -34
View File
@@ -14,6 +14,13 @@
*/
Object.assign(CodemanApp.prototype, {
_addActivityEntry(agentId, entry, maxSize = 50) {
const activity = this.subagentActivity.get(agentId) || [];
activity.push(entry);
if (activity.length > maxSize) activity.shift();
this.subagentActivity.set(agentId, activity);
},
// Tasks
_onTaskCreated(data) {
this.renderSessionTabs();
@@ -106,15 +113,7 @@ Object.assign(CodemanApp.prototype, {
// Notify about new subagent discovery
const parentId = this.subagentParentMap.get(data.agentId);
const parentSession = parentId ? this.sessions.get(parentId) : null;
this.notificationManager?.notify({
urgency: 'info',
category: 'subagent-spawn',
sessionId: parentId || data.sessionId,
sessionName: parentSession?.name || parentId || data.sessionId,
title: 'Subagent Spawned',
message: data.description || 'New background agent started',
});
this._notifySession(parentId || data.sessionId, 'info', 'subagent-spawn', 'Subagent Spawned', data.description || 'New background agent started');
},
_onSubagentUpdated(data) {
@@ -135,10 +134,7 @@ Object.assign(CodemanApp.prototype, {
},
_onSubagentToolCall(data) {
const activity = this.subagentActivity.get(data.agentId) || [];
activity.push({ type: 'tool', ...data });
if (activity.length > 50) activity.shift(); // Keep last 50 entries
this.subagentActivity.set(data.agentId, activity);
this._addActivityEntry(data.agentId, { type: 'tool', ...data });
if (this.activeSubagentId === data.agentId) {
this.renderSubagentDetail();
}
@@ -150,10 +146,7 @@ Object.assign(CodemanApp.prototype, {
},
_onSubagentProgress(data) {
const activity = this.subagentActivity.get(data.agentId) || [];
activity.push({ type: 'progress', ...data });
if (activity.length > 50) activity.shift();
this.subagentActivity.set(data.agentId, activity);
this._addActivityEntry(data.agentId, { type: 'progress', ...data });
if (this.activeSubagentId === data.agentId) {
this.renderSubagentDetail();
}
@@ -164,10 +157,7 @@ Object.assign(CodemanApp.prototype, {
},
_onSubagentMessage(data) {
const activity = this.subagentActivity.get(data.agentId) || [];
activity.push({ type: 'message', ...data });
if (activity.length > 50) activity.shift();
this.subagentActivity.set(data.agentId, activity);
this._addActivityEntry(data.agentId, { type: 'message', ...data });
if (this.activeSubagentId === data.agentId) {
this.renderSubagentDetail();
}
@@ -190,10 +180,7 @@ Object.assign(CodemanApp.prototype, {
}
// Add to activity stream
const activity = this.subagentActivity.get(data.agentId) || [];
activity.push({ type: 'tool_result', ...data });
if (activity.length > 50) activity.shift();
this.subagentActivity.set(data.agentId, activity);
this._addActivityEntry(data.agentId, { type: 'tool_result', ...data });
if (this.activeSubagentId === data.agentId) {
this.renderSubagentDetail();
@@ -224,15 +211,7 @@ Object.assign(CodemanApp.prototype, {
// Notify about subagent completion
const parentId = this.subagentParentMap.get(data.agentId);
const parentSession = parentId ? this.sessions.get(parentId) : null;
this.notificationManager?.notify({
urgency: 'info',
category: 'subagent-complete',
sessionId: parentId || existing?.sessionId || data.sessionId,
sessionName: parentSession?.name || parentId || data.sessionId,
title: 'Subagent Completed',
message: existing?.description || data.description || 'Background agent finished',
});
this._notifySession(parentId || existing?.sessionId || data.sessionId, 'info', 'subagent-complete', 'Subagent Completed', existing?.description || data.description || 'Background agent finished');
// Clean up activity/tool data for completed agents after 5 minutes
// This prevents memory leaks from long-running sessions with many subagents
+3 -27
View File
@@ -45,15 +45,7 @@ Object.assign(CodemanApp.prototype, {
this.updateRalphState(data.sessionId, existing);
}
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'warning',
category: 'ralph-complete',
sessionId: data.sessionId,
sessionName: session?.name || this.getShortId(data.sessionId),
title: 'Loop Complete',
message: `Completion: ${data.phrase || 'unknown'}`,
});
this._notifySession(data.sessionId, 'warning', 'ralph-complete', 'Loop Complete', `Completion: ${data.phrase || 'unknown'}`);
},
_onRalphStatusUpdate(data) {
@@ -68,28 +60,12 @@ Object.assign(CodemanApp.prototype, {
this.updateRalphState(data.sessionId, { circuitBreaker: data.status });
// Notify if circuit breaker opens
if (data.status.state === 'OPEN') {
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'critical',
category: 'circuit-breaker',
sessionId: data.sessionId,
sessionName: session?.name || this.getShortId(data.sessionId),
title: 'Circuit Breaker Open',
message: data.status.reason || 'Loop stuck - no progress detected',
});
this._notifySession(data.sessionId, 'critical', 'circuit-breaker', 'Circuit Breaker Open', data.status.reason || 'Loop stuck - no progress detected');
}
},
_onExitGateMet(data) {
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'warning',
category: 'exit-gate',
sessionId: data.sessionId,
sessionName: session?.name || this.getShortId(data.sessionId),
title: 'Exit Gate Met',
message: `Loop ready to exit (indicators: ${data.completionIndicators})`,
});
this._notifySession(data.sessionId, 'warning', 'exit-gate', 'Exit Gate Met', `Loop ready to exit (indicators: ${data.completionIndicators})`);
},
// Bash tools
+3 -26
View File
@@ -44,21 +44,13 @@ Object.assign(CodemanApp.prototype, {
},
_onRespawnBlocked(data) {
const session = this.sessions.get(data.sessionId);
const reasonMap = {
circuit_breaker_open: 'Circuit Breaker Open',
exit_signal: 'Exit Signal Detected',
status_blocked: 'Claude Reported BLOCKED',
};
const title = reasonMap[data.reason] || 'Respawn Blocked';
this.notificationManager?.notify({
urgency: 'critical',
category: 'respawn-blocked',
sessionId: data.sessionId,
sessionName: session?.name || this.getShortId(data.sessionId),
title,
message: data.details,
});
this._notifySession(data.sessionId, 'critical', 'respawn-blocked', title, data.details);
// Update respawn panel to show blocked state
if (data.sessionId === this.activeSessionId) {
const stateEl = document.getElementById('respawnStateLabel');
@@ -71,14 +63,7 @@ Object.assign(CodemanApp.prototype, {
_onRespawnAutoAcceptSent(data) {
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'info',
category: 'auto-accept',
sessionId: data.sessionId,
sessionName: session?.name || this.getShortId(data.sessionId),
title: 'Plan Accepted',
message: `Accepted plan mode for ${session?.name || 'session'}`,
});
this._notifySession(data.sessionId, 'info', 'auto-accept', 'Plan Accepted', `Accepted plan mode for ${session?.name || 'session'}`);
},
_onRespawnDetectionUpdate(data) {
@@ -144,15 +129,7 @@ Object.assign(CodemanApp.prototype, {
},
_onRespawnError(data) {
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'critical',
category: 'session-error',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId,
title: 'Respawn Error',
message: data.error || data.message || 'Respawn encountered an error',
});
this._notifySession(data.sessionId, 'critical', 'session-error', 'Respawn Error', data.error || data.message || 'Respawn encountered an error');
},
_onRespawnActionLog(data) {
+1 -7
View File
@@ -109,16 +109,10 @@ Object.assign(CodemanApp.prototype, {
async saveLastUsedCase(caseName) {
try {
// Get current settings
const res = await fetch('/api/settings');
const settings = res.ok ? await res.json() : {};
// Update lastUsedCase
settings.lastUsedCase = caseName;
// Save back
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
body: JSON.stringify({ lastUsedCase: caseName })
});
} catch (err) {
console.error('Failed to save last used case:', err);
+18 -76
View File
@@ -14,92 +14,46 @@
Object.assign(CodemanApp.prototype, {
// Hooks (Claude Code hook events)
_onHookIdlePrompt(data) {
const session = this.sessions.get(data.sessionId);
// Always track pending hook - alert will show when switching away from session
if (data.sessionId) {
this.setPendingHook(data.sessionId, 'idle_prompt');
}
this.notificationManager?.notify({
urgency: 'warning',
category: 'hook-idle',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId,
title: 'Waiting for Input',
message: data.message || 'Claude is idle and waiting for a prompt',
});
this._notifySession(data.sessionId, 'warning', 'hook-idle', 'Waiting for Input', data.message || 'Claude is idle and waiting for a prompt');
},
_onHookPermissionPrompt(data) {
const session = this.sessions.get(data.sessionId);
// Always track pending hook - action alerts need user interaction to clear
if (data.sessionId) {
this.setPendingHook(data.sessionId, 'permission_prompt');
}
const toolInfo = data.tool ? `${data.tool}${data.command ? ': ' + data.command : data.file ? ': ' + data.file : ''}` : '';
this.notificationManager?.notify({
urgency: 'critical',
category: 'hook-permission',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId,
title: 'Permission Required',
message: toolInfo || 'Claude needs tool approval to continue',
});
this._notifySession(data.sessionId, 'critical', 'hook-permission', 'Permission Required', toolInfo || 'Claude needs tool approval to continue');
},
_onHookElicitationDialog(data) {
const session = this.sessions.get(data.sessionId);
// Always track pending hook - action alerts need user interaction to clear
if (data.sessionId) {
this.setPendingHook(data.sessionId, 'elicitation_dialog');
}
this.notificationManager?.notify({
urgency: 'critical',
category: 'hook-elicitation',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId,
title: 'Question Asked',
message: data.question || 'Claude is asking a question and waiting for your answer',
});
this._notifySession(data.sessionId, 'critical', 'hook-elicitation', 'Question Asked', data.question || 'Claude is asking a question and waiting for your answer');
},
_onHookStop(data) {
const session = this.sessions.get(data.sessionId);
// Clear all pending hooks when Claude finishes responding
if (data.sessionId) {
this.clearPendingHooks(data.sessionId);
}
this.notificationManager?.notify({
urgency: 'info',
category: 'hook-stop',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId,
title: 'Response Complete',
message: data.reason || 'Claude has finished responding',
});
this._notifySession(data.sessionId, 'info', 'hook-stop', 'Response Complete', data.reason || 'Claude has finished responding');
},
_onHookTeammateIdle(data) {
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'warning',
category: 'hook-teammate-idle',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId,
title: 'Teammate Idle',
message: `A teammate is idle in ${session?.name || data.sessionId}`,
});
this._notifySession(data.sessionId, 'warning', 'hook-teammate-idle', 'Teammate Idle', `A teammate is idle in ${session?.name || data.sessionId}`);
},
_onHookTaskCompleted(data) {
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'info',
category: 'hook-task-completed',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId,
title: 'Task Completed',
message: `A team task completed in ${session?.name || data.sessionId}`,
});
this._notifySession(data.sessionId, 'info', 'hook-task-completed', 'Task Completed', `A team task completed in ${session?.name || data.sessionId}`);
},
@@ -517,16 +471,17 @@ Object.assign(CodemanApp.prototype, {
}
},
_updateTunnelUrlDisplay(url) {
const row = document.getElementById('tunnelUrlRow');
const display = document.getElementById('tunnelUrlDisplay');
_updateTunnelUrlRow(rowId, displayId, url, suffix = '') {
const row = document.getElementById(rowId);
const display = document.getElementById(displayId);
if (!row || !display) return;
if (url) {
const fullUrl = url + suffix;
row.style.display = '';
display.textContent = url;
display.textContent = fullUrl;
display.onclick = () => {
navigator.clipboard.writeText(url).then(() => {
this.showToast('Tunnel URL copied', 'success');
navigator.clipboard.writeText(fullUrl).then(() => {
this.showToast(`${suffix ? 'Upload' : 'Tunnel'} URL copied`, 'success');
});
};
} else {
@@ -534,24 +489,11 @@ Object.assign(CodemanApp.prototype, {
display.textContent = '';
display.onclick = null;
}
// Upload URL row
const uploadRow = document.getElementById('tunnelUploadUrlRow');
const uploadDisplay = document.getElementById('tunnelUploadUrlDisplay');
if (!uploadRow || !uploadDisplay) return;
if (url) {
const uploadUrl = url + '/upload.html';
uploadRow.style.display = '';
uploadDisplay.textContent = uploadUrl;
uploadDisplay.onclick = () => {
navigator.clipboard.writeText(uploadUrl).then(() => {
this.showToast('Upload URL copied', 'success');
});
};
} else {
uploadRow.style.display = 'none';
uploadDisplay.textContent = '';
uploadDisplay.onclick = null;
}
},
_updateTunnelUrlDisplay(url) {
this._updateTunnelUrlRow('tunnelUrlRow', 'tunnelUrlDisplay', url);
this._updateTunnelUrlRow('tunnelUploadUrlRow', 'tunnelUploadUrlDisplay', url, '/upload.html');
},
showTunnelQR() {
+360
View File
@@ -0,0 +1,360 @@
/**
* @fileoverview Respawn event wiring — pure functions that connect RespawnController
* events to SSE broadcasts, push notifications, and run summary tracking.
*
* Extracted from WebServer to keep respawn-specific event plumbing separate from
* HTTP/session concerns. Follows the same DI pattern as session-listener-wiring.ts.
*/
import { Session } from '../session.js';
import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js';
import type { PersistedRespawnConfig } from '../types.js';
import type { RunSummaryTracker } from '../run-summary.js';
import type { TerminalMultiplexer } from '../mux-interface.js';
import type { TeamWatcher } from '../team-watcher.js';
import { SseEvent } from './sse-events.js';
// ============================================================================
// Dependency Interface
// ============================================================================
export interface RespawnWiringDeps {
broadcast(event: string, data: unknown): void;
sendPushNotifications(event: string, data: Record<string, unknown>): void;
persistSessionState(session: Session): void;
getSession(sessionId: string): Session | undefined;
sessionExists(sessionId: string): boolean;
getRunSummaryTracker(sessionId: string): RunSummaryTracker | undefined;
getRespawnControllers(): Map<string, RespawnController>;
getRespawnTimers(): Map<string, { timer: NodeJS.Timeout; endAt: number; startedAt: number }>;
getPendingRespawnStarts(): Map<string, NodeJS.Timeout>;
teamWatcher: TeamWatcher;
serverStartTime: number;
respawnRestoreGracePeriodMs: number;
mux: TerminalMultiplexer;
}
// ============================================================================
// Respawn Listener Wiring
// ============================================================================
/**
* Wire a RespawnController's events to SSE broadcasts, push notifications,
* and run summary tracking.
*/
export function wireRespawnListeners(sessionId: string, controller: RespawnController, deps: RespawnWiringDeps): void {
// Wire team watcher for team-aware idle detection
controller.setTeamWatcher(deps.teamWatcher);
// Helper to get tracker lazily (may not exist at setup time for restored sessions)
const getTracker = () => deps.getRunSummaryTracker(sessionId);
// ─── Respawn State Machine ──────────────────────────────
/** Broadcasts `respawn:stateChanged` — state machine transition (e.g., IDLE → DETECTING → RESPAWNING) */
controller.on('stateChanged', (state: RespawnState, prevState: RespawnState) => {
deps.broadcast(SseEvent.RespawnStateChanged, { sessionId, state, prevState });
const tracker = getTracker();
if (tracker) tracker.recordStateChange(state, `${prevState} → ${state}`);
});
// ─── Respawn Cycle Lifecycle ────────────────────────────
/** Broadcasts `respawn:cycleStarted` — new respawn cycle begins */
controller.on('respawnCycleStarted', (cycleNumber: number) => {
deps.broadcast(SseEvent.RespawnCycleStarted, { sessionId, cycleNumber });
});
/** Broadcasts `respawn:cycleCompleted` — respawn cycle finished */
controller.on('respawnCycleCompleted', (cycleNumber: number) => {
deps.broadcast(SseEvent.RespawnCycleCompleted, { sessionId, cycleNumber });
});
/** Broadcasts `respawn:blocked` + push notification — respawn blocked by error/circuit breaker */
controller.on('respawnBlocked', (data: { reason: string; details: string }) => {
deps.broadcast(SseEvent.RespawnBlocked, { sessionId, reason: data.reason, details: data.details });
const sessionForPush = deps.getSession(sessionId);
deps.sendPushNotifications(SseEvent.RespawnBlocked, {
sessionId,
sessionName: sessionForPush?.name ?? sessionId.slice(0, 8),
reason: data.reason,
});
const tracker = getTracker();
if (tracker) tracker.recordWarning(`Respawn blocked: ${data.reason}`, data.details);
});
// ─── Respawn Step Progress ──────────────────────────────
/** Broadcasts `respawn:stepSent` — respawn step input sent (e.g., /clear, kickstart prompt) */
controller.on('stepSent', (step: string, input: string) => {
deps.broadcast(SseEvent.RespawnStepSent, { sessionId, step, input });
});
/** Broadcasts `respawn:stepCompleted` — respawn step finished */
controller.on('stepCompleted', (step: string) => {
deps.broadcast(SseEvent.RespawnStepCompleted, { sessionId, step });
});
/** Broadcasts `respawn:detectionUpdate` — idle/completion detection state changed */
controller.on('detectionUpdate', (detection: unknown) => {
deps.broadcast(SseEvent.RespawnDetectionUpdate, { sessionId, detection });
});
/** Broadcasts `respawn:autoAcceptSent` — auto-accepted a permission prompt */
controller.on('autoAcceptSent', () => {
deps.broadcast(SseEvent.RespawnAutoAcceptSent, { sessionId });
});
// ─── AI Checker Events ──────────────────────────────────
/** Broadcasts `respawn:aiCheckStarted` — AI idle checker invoked */
controller.on('aiCheckStarted', () => {
deps.broadcast(SseEvent.RespawnAiCheckStarted, { sessionId });
});
/** Broadcasts `respawn:aiCheckCompleted` — AI idle check returned verdict (idle/working/stuck) */
controller.on('aiCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => {
deps.broadcast(SseEvent.RespawnAiCheckCompleted, {
sessionId,
verdict: result.verdict,
reasoning: result.reasoning,
durationMs: result.durationMs,
});
const tracker = getTracker();
if (tracker) tracker.recordAiCheckResult(result.verdict);
});
/** Broadcasts `respawn:aiCheckFailed` — AI idle check errored */
controller.on('aiCheckFailed', (error: string) => {
deps.broadcast(SseEvent.RespawnAiCheckFailed, { sessionId, error });
const tracker = getTracker();
if (tracker) tracker.recordError('AI check failed', error);
});
/** Broadcasts `respawn:aiCheckCooldown` — AI check on cooldown after failure */
controller.on('aiCheckCooldown', (active: boolean, endsAt: number | null) => {
deps.broadcast(SseEvent.RespawnAiCheckCooldown, { sessionId, active, endsAt });
});
// ─── Plan Checker Events ────────────────────────────────
/** Broadcasts `respawn:planCheckStarted` — AI plan completion checker invoked */
controller.on('planCheckStarted', () => {
deps.broadcast(SseEvent.RespawnPlanCheckStarted, { sessionId });
});
/** Broadcasts `respawn:planCheckCompleted` — plan check returned verdict */
controller.on('planCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => {
deps.broadcast(SseEvent.RespawnPlanCheckCompleted, {
sessionId,
verdict: result.verdict,
reasoning: result.reasoning,
durationMs: result.durationMs,
});
});
/** Broadcasts `respawn:planCheckFailed` — plan check errored */
controller.on('planCheckFailed', (error: string) => {
deps.broadcast(SseEvent.RespawnPlanCheckFailed, { sessionId, error });
});
// ─── Timer Events (UI countdown display) ────────────────
/** Broadcasts `respawn:timerStarted` — countdown timer started (idle, cooldown, etc.) */
controller.on('timerStarted', (timer) => {
deps.broadcast(SseEvent.RespawnTimerStarted, { sessionId, timer });
});
/** Broadcasts `respawn:timerCancelled` — timer cancelled before expiry */
controller.on('timerCancelled', (timerName, reason) => {
deps.broadcast(SseEvent.RespawnTimerCancelled, { sessionId, timerName, reason });
});
/** Broadcasts `respawn:timerCompleted` — timer expired */
controller.on('timerCompleted', (timerName) => {
deps.broadcast(SseEvent.RespawnTimerCompleted, { sessionId, timerName });
});
// ─── Logging & Errors ───────────────────────────────────
/** Broadcasts `respawn:actionLog` — respawn action logged for audit/debugging */
controller.on('actionLog', (action) => {
deps.broadcast(SseEvent.RespawnActionLog, { sessionId, action });
});
/** Broadcasts `respawn:log` — general respawn log message */
controller.on('log', (message: string) => {
deps.broadcast(SseEvent.RespawnLog, { sessionId, message });
});
/** Broadcasts `respawn:error` — respawn controller error */
controller.on('error', (error: Error) => {
deps.broadcast(SseEvent.RespawnError, { sessionId, error: error.message });
const tracker = getTracker();
if (tracker) tracker.recordError('Respawn error', error.message);
});
}
// ============================================================================
// Timed Respawn
// ============================================================================
/**
* Set up a duration-limited respawn timer that stops respawn after N minutes.
*/
export function setupTimedRespawn(sessionId: string, durationMinutes: number, deps: RespawnWiringDeps): void {
const timers = deps.getRespawnTimers();
// Clear existing timer if any
const existing = timers.get(sessionId);
if (existing) {
clearTimeout(existing.timer);
}
const now = Date.now();
const endAt = now + durationMinutes * 60 * 1000;
const timer = setTimeout(
() => {
// Stop respawn when time is up
const controllers = deps.getRespawnControllers();
const controller = controllers.get(sessionId);
if (controller) {
controller.stop();
controller.removeAllListeners();
controllers.delete(sessionId);
deps.broadcast(SseEvent.RespawnStopped, { sessionId, reason: 'duration_expired' });
}
timers.delete(sessionId);
// Update persisted state (respawn no longer active)
const session = deps.getSession(sessionId);
if (session) {
deps.persistSessionState(session);
}
},
durationMinutes * 60 * 1000
);
timers.set(sessionId, { timer, endAt, startedAt: now });
deps.broadcast(SseEvent.RespawnTimerStarted, { sessionId, durationMinutes, endAt, startedAt: now });
}
// ============================================================================
// Respawn Controller Restore
// ============================================================================
/**
* Restore a RespawnController from persisted configuration.
* Creates the controller, wires listeners, and starts after a grace period.
*/
export function restoreRespawnController(
session: Session,
config: PersistedRespawnConfig,
source: string,
deps: RespawnWiringDeps
): void {
const controller = new RespawnController(session, {
idleTimeoutMs: config.idleTimeoutMs,
updatePrompt: config.updatePrompt,
interStepDelayMs: config.interStepDelayMs,
enabled: true,
sendClear: config.sendClear,
sendInit: config.sendInit,
kickstartPrompt: config.kickstartPrompt,
completionConfirmMs: config.completionConfirmMs,
noOutputTimeoutMs: config.noOutputTimeoutMs,
autoAcceptPrompts: config.autoAcceptPrompts,
autoAcceptDelayMs: config.autoAcceptDelayMs,
aiIdleCheckEnabled: config.aiIdleCheckEnabled,
aiIdleCheckModel: config.aiIdleCheckModel,
aiIdleCheckMaxContext: config.aiIdleCheckMaxContext,
aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs,
aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs,
aiPlanCheckEnabled: config.aiPlanCheckEnabled,
aiPlanCheckModel: config.aiPlanCheckModel,
aiPlanCheckMaxContext: config.aiPlanCheckMaxContext,
aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs,
aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs,
});
const controllers = deps.getRespawnControllers();
controllers.set(session.id, controller);
wireRespawnListeners(session.id, controller, deps);
// Calculate delay: wait until grace period after server start before starting respawn
// This prevents false idle detection immediately after a server restart/rebuild
const timeSinceStart = Date.now() - deps.serverStartTime;
const delayMs = Math.max(0, deps.respawnRestoreGracePeriodMs - timeSinceStart);
const pendingStarts = deps.getPendingRespawnStarts();
if (delayMs > 0) {
console.log(
`[Server] Restored respawn controller for session ${session.id} from ${source} (will start in ${Math.ceil(delayMs / 1000)}s)`
);
const delayTimer = setTimeout(() => {
pendingStarts.delete(session.id);
// Verify session still exists (may have been deleted during grace period)
if (!deps.sessionExists(session.id)) {
console.log(`[Server] Skipping restored respawn start - session ${session.id} no longer exists`);
return;
}
// Double-check controller still exists and is stopped
const ctrl = controllers.get(session.id);
if (ctrl && ctrl.state === 'stopped') {
ctrl.start();
deps.broadcast(SseEvent.RespawnStarted, { sessionId: session.id });
console.log(`[Server] Restored respawn controller started for session ${session.id}`);
}
}, delayMs);
pendingStarts.set(session.id, delayTimer);
} else {
// Grace period has passed, start immediately
controller.start();
console.log(`[Server] Restored respawn controller for session ${session.id} from ${source} (started immediately)`);
}
if (config.durationMinutes && config.durationMinutes > 0) {
setupTimedRespawn(session.id, config.durationMinutes, deps);
}
}
// ============================================================================
// Respawn Config Persistence
// ============================================================================
/**
* Save respawn config to mux for restart recovery.
*/
export function saveRespawnConfig(
sessionId: string,
config: RespawnConfig,
mux: TerminalMultiplexer,
durationMinutes?: number
): void {
const persistedConfig: PersistedRespawnConfig = {
enabled: config.enabled,
idleTimeoutMs: config.idleTimeoutMs,
updatePrompt: config.updatePrompt,
interStepDelayMs: config.interStepDelayMs,
sendClear: config.sendClear,
sendInit: config.sendInit,
kickstartPrompt: config.kickstartPrompt,
autoAcceptPrompts: config.autoAcceptPrompts,
autoAcceptDelayMs: config.autoAcceptDelayMs,
completionConfirmMs: config.completionConfirmMs,
noOutputTimeoutMs: config.noOutputTimeoutMs,
aiIdleCheckEnabled: config.aiIdleCheckEnabled,
aiIdleCheckModel: config.aiIdleCheckModel,
aiIdleCheckMaxContext: config.aiIdleCheckMaxContext,
aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs,
aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs,
aiPlanCheckEnabled: config.aiPlanCheckEnabled,
aiPlanCheckModel: config.aiPlanCheckModel,
aiPlanCheckMaxContext: config.aiPlanCheckMaxContext,
aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs,
aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs,
durationMinutes,
};
mux.updateRespawnConfig(sessionId, persistedConfig);
}
+61
View File
@@ -6,6 +6,8 @@
*/
import { join, resolve, relative, isAbsolute } from 'node:path';
import { realpathSync } from 'node:fs';
import fs from 'node:fs/promises';
import { homedir } from 'node:os';
import type { z } from 'zod';
import { Session } from '../session.js';
@@ -33,6 +35,45 @@ export function validatePathWithinBase(name: string, baseDir: string): string |
return fullPath;
}
/**
* Reads and parses a JSON config file, returning a default value on ENOENT.
* Logs an error for any I/O failure other than a missing file.
*/
export async function readJsonConfig<T>(filePath: string, logLabel: string, defaultValue: T): Promise<T> {
try {
const content = await fs.readFile(filePath, 'utf-8');
return JSON.parse(content) as T;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error(`Failed to read ${logLabel}:`, err);
}
return defaultValue;
}
}
/**
* Validates that a file path (possibly containing symlinks) resolves to a location
* within the given session working directory. Returns the resolved and relative paths,
* or null if the path escapes the directory or doesn't exist.
*/
export function validateSessionFilePath(
sessionWorkingDir: string,
filePath: string
): { resolvedPath: string; relativePath: string } | null {
const fullPath = resolve(sessionWorkingDir, filePath);
let resolvedPath: string;
try {
resolvedPath = realpathSync(fullPath);
} catch {
return null;
}
const relativePath = relative(sessionWorkingDir, resolvedPath);
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
return null;
}
return { resolvedPath, relativePath };
}
// Maximum hook data size (prevents oversized SSE broadcasts)
const MAX_HOOK_DATA_SIZE = 8 * 1024;
@@ -144,6 +185,26 @@ export function sanitizeHookData(data: Record<string, unknown> | null | undefine
return safeFields;
}
/**
* Toggles a service (watcher/manager) on or off based on an enabled flag.
* Logs start/stop to console with the given label. Runs an optional callback after starting.
*/
export function toggleService(
enabled: boolean,
service: { isRunning(): boolean; start(): void; stop(): void },
label: string,
onStart?: () => void
): void {
if (enabled && !service.isRunning()) {
service.start();
onStart?.();
console.log(`${label} started via settings change`);
} else if (!enabled && service.isRunning()) {
service.stop();
console.log(`${label} stopped via settings change`);
}
}
/**
* Auto-configure Ralph tracker for a session.
*
+2 -10
View File
@@ -14,7 +14,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.
import { CreateCaseSchema, LinkCaseSchema } from '../schemas.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { writeHooksConfig } from '../../hooks-config.js';
import { CASES_DIR, validatePathWithinBase, parseBody } from '../route-helpers.js';
import { CASES_DIR, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
import { SseEvent } from '../sse-events.js';
import type { EventPort, ConfigPort } from '../ports/index.js';
@@ -22,15 +22,7 @@ const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json');
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
async function readLinkedCases(): Promise<Record<string, string>> {
try {
return JSON.parse(await fs.readFile(LINKED_CASES_FILE, 'utf-8'));
} catch (err) {
// Only warn on real I/O errors, not ENOENT (file missing) or SyntaxError (corrupted JSON)
if ((err as NodeJS.ErrnoException).code && (err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn('[Server] Failed to read linked cases:', err);
}
return {};
}
return readJsonConfig<Record<string, string>>(LINKED_CASES_FILE, 'linked cases', {});
}
/** Resolve a case name to its directory path, checking linked cases first, then CASES_DIR. */
+8 -22
View File
@@ -4,12 +4,11 @@
*/
import { FastifyInstance } from 'fastify';
import { join, resolve, relative, isAbsolute } from 'node:path';
import { realpathSync } from 'node:fs';
import { join } from 'node:path';
import fs from 'node:fs/promises';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { fileStreamManager } from '../../file-stream-manager.js';
import { findSessionOrFail } from '../route-helpers.js';
import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js';
import type { SessionPort } from '../ports/index.js';
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void {
@@ -148,17 +147,11 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
}
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
const fullPath = resolve(session.workingDir, filePath);
let resolvedPath: string;
try {
resolvedPath = realpathSync(fullPath);
} catch {
const validated = validateSessionFilePath(session.workingDir, filePath);
if (!validated) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found');
}
const relativePath = relative(session.workingDir, resolvedPath);
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory');
}
const { resolvedPath } = validated;
try {
const stat = await fs.stat(resolvedPath);
@@ -255,19 +248,12 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
}
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
const fullPath = resolve(session.workingDir, filePath);
let resolvedPath: string;
try {
resolvedPath = realpathSync(fullPath);
} catch {
const validated = validateSessionFilePath(session.workingDir, filePath);
if (!validated) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
}
const relativePath = relative(session.workingDir, resolvedPath);
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory'));
return;
}
const { resolvedPath } = validated;
try {
// Validate file size before reading (DoS protection - prevent memory exhaustion)
+24 -30
View File
@@ -39,43 +39,37 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
return loop;
}
const EVENT_MAP: [string, (typeof SseEvent)[keyof typeof SseEvent], string[]][] = [
['stateChanged', SseEvent.OrchestratorStateChanged, ['state', 'prevState']],
['planProgress', SseEvent.OrchestratorPlanProgress, ['phase', 'detail']],
['planReady', SseEvent.OrchestratorPlanReady, ['plan']],
['phaseStarted', SseEvent.OrchestratorPhaseStarted, ['phase']],
['phaseCompleted', SseEvent.OrchestratorPhaseCompleted, ['phase']],
['phaseFailed', SseEvent.OrchestratorPhaseFailed, ['phase', 'reason']],
['taskAssigned', SseEvent.OrchestratorTaskAssigned, ['task', 'sessionId']],
['taskCompleted', SseEvent.OrchestratorTaskCompleted, ['task']],
['taskFailed', SseEvent.OrchestratorTaskFailed, ['task', 'error']],
['completed', SseEvent.OrchestratorCompleted, ['stats']],
];
let forwardingLoop: import('../../orchestrator-loop.js').OrchestratorLoop | null = null;
function setupEventForwarding(loop: import('../../orchestrator-loop.js').OrchestratorLoop) {
if (forwardingLoop === loop) return; // Already attached to this loop instance
forwardingLoop = loop;
loop.on('stateChanged', (state, prevState) => {
ctx.broadcast(SseEvent.OrchestratorStateChanged, { state, prevState });
});
loop.on('planProgress', (phase, detail) => {
ctx.broadcast(SseEvent.OrchestratorPlanProgress, { phase, detail });
});
loop.on('planReady', (plan) => {
ctx.broadcast(SseEvent.OrchestratorPlanReady, { plan });
});
loop.on('phaseStarted', (phase) => {
ctx.broadcast(SseEvent.OrchestratorPhaseStarted, { phase });
});
loop.on('phaseCompleted', (phase) => {
ctx.broadcast(SseEvent.OrchestratorPhaseCompleted, { phase });
});
loop.on('phaseFailed', (phase, reason) => {
ctx.broadcast(SseEvent.OrchestratorPhaseFailed, { phase, reason });
});
for (const [event, sseEvent, argNames] of EVENT_MAP) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
loop.on(event, (...args: any[]) => {
const payload: Record<string, unknown> = {};
argNames.forEach((name, i) => {
payload[name] = args[i];
});
ctx.broadcast(sseEvent, payload);
});
}
// Special cases with non-trivial payload transforms
loop.on('verificationResult', (phase, result) => {
ctx.broadcast(SseEvent.OrchestratorVerification, { phaseId: phase.id, result });
});
loop.on('taskAssigned', (task, sessionId) => {
ctx.broadcast(SseEvent.OrchestratorTaskAssigned, { task, sessionId });
});
loop.on('taskCompleted', (task) => {
ctx.broadcast(SseEvent.OrchestratorTaskCompleted, { task });
});
loop.on('taskFailed', (task, error) => {
ctx.broadcast(SseEvent.OrchestratorTaskFailed, { task, error });
});
loop.on('completed', (stats) => {
ctx.broadcast(SseEvent.OrchestratorCompleted, { stats });
});
loop.on('error', (error) => {
ctx.broadcast(SseEvent.OrchestratorError, { error: error.message });
});
+25 -65
View File
@@ -24,7 +24,14 @@ import {
import { subagentWatcher } from '../../subagent-watcher.js';
import { imageWatcher } from '../../image-watcher.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
import { findSessionOrFail, formatUptime, parseBody, SETTINGS_PATH } from '../route-helpers.js';
import {
findSessionOrFail,
formatUptime,
parseBody,
readJsonConfig,
toggleService,
SETTINGS_PATH,
} from '../route-helpers.js';
import { SseEvent } from '../sse-events.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
@@ -281,15 +288,20 @@ export function registerSystemRoutes(
// ========== Stats ==========
app.get('/api/stats', async () => {
const activeSessionTokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
function collectActiveTokens(): Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> {
const tokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
for (const [sessionId, session] of ctx.sessions) {
activeSessionTokens[sessionId] = {
tokens[sessionId] = {
inputTokens: session.inputTokens,
outputTokens: session.outputTokens,
totalCost: session.totalCost,
};
}
return tokens;
}
app.get('/api/stats', async () => {
const activeSessionTokens = collectActiveTokens();
return {
success: true,
stats: ctx.store.getAggregateStats(activeSessionTokens),
@@ -298,14 +310,7 @@ export function registerSystemRoutes(
});
app.get('/api/token-stats', async () => {
const activeSessionTokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
for (const [sessionId, session] of ctx.sessions) {
activeSessionTokens[sessionId] = {
inputTokens: session.inputTokens,
outputTokens: session.outputTokens,
totalCost: session.totalCost,
};
}
const activeSessionTokens = collectActiveTokens();
return {
success: true,
daily: ctx.store.getDailyStats(30),
@@ -393,15 +398,7 @@ export function registerSystemRoutes(
// ========== Settings ==========
app.get('/api/settings', async () => {
try {
const content = await fs.readFile(SETTINGS_PATH, 'utf-8');
return JSON.parse(content);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('Failed to read settings:', err);
}
}
return {};
return readJsonConfig(SETTINGS_PATH, 'settings', {});
});
app.put('/api/settings', async (req) => {
@@ -422,30 +419,17 @@ export function registerSystemRoutes(
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
// Handle subagent tracking toggle dynamically
const subagentEnabled = settings.subagentTrackingEnabled ?? true;
if (subagentEnabled && !subagentWatcher.isRunning()) {
subagentWatcher.start();
console.log('Subagent watcher started via settings change');
} else if (!subagentEnabled && subagentWatcher.isRunning()) {
subagentWatcher.stop();
console.log('Subagent watcher stopped via settings change');
}
toggleService((settings.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
// Handle image watcher toggle dynamically
const imageWatcherEnabled = settings.imageWatcherEnabled ?? false;
if (imageWatcherEnabled && !imageWatcher.isRunning()) {
imageWatcher.start();
toggleService((settings.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
// Re-watch all active sessions that have image watcher enabled
for (const session of ctx.sessions.values()) {
if (session.imageWatcherEnabled) {
imageWatcher.watchSession(session.id, session.workingDir);
}
}
console.log('Image watcher started via settings change');
} else if (!imageWatcherEnabled && imageWatcher.isRunning()) {
imageWatcher.stop();
console.log('Image watcher stopped via settings change');
}
});
// Handle tunnel toggle dynamically
if ('tunnelEnabled' in settings) {
@@ -472,16 +456,8 @@ export function registerSystemRoutes(
// ========== Model Configuration ==========
app.get('/api/execution/model-config', async () => {
try {
const content = await fs.readFile(SETTINGS_PATH, 'utf-8');
const settings = JSON.parse(content);
return { success: true, data: settings.modelConfig || {} };
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('Failed to read model config:', err);
}
return { success: true, data: {} };
}
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'model config', {});
return { success: true, data: settings.modelConfig || {} };
});
app.put('/api/execution/model-config', async (req) => {
@@ -545,15 +521,7 @@ export function registerSystemRoutes(
// ========== Subagent Window State Persistence ==========
app.get('/api/subagent-window-states', async () => {
try {
const content = await fs.readFile(windowStatesPath, 'utf-8');
return JSON.parse(content);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('Failed to read subagent window states:', err);
}
}
return { minimized: {}, open: [] };
return readJsonConfig(windowStatesPath, 'subagent window states', { minimized: {}, open: [] });
});
app.put('/api/subagent-window-states', async (req) => {
@@ -573,15 +541,7 @@ export function registerSystemRoutes(
// ========== Subagent Parent Associations ==========
app.get('/api/subagent-parents', async () => {
try {
const content = await fs.readFile(parentMapPath, 'utf-8');
return JSON.parse(content);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('Failed to read subagent parent map:', err);
}
}
return {};
return readJsonConfig(parentMapPath, 'subagent parent map', {});
});
app.put('/api/subagent-parents', async (req) => {
+118 -1083
View File
File diff suppressed because it is too large Load Diff
+392
View File
@@ -0,0 +1,392 @@
/**
* @fileoverview Session event listener wiring — creates, attaches, and detaches session listeners.
*
* Extracted from server.ts for modularity. Provides:
* - `SessionListenerRefs` interface (named listener references for leak-free cleanup)
* - `createSessionListeners()` — builds all 25 listener handlers via dependency injection
* - `attachSessionListeners()` / `detachSessionListeners()` — symmetric attach/detach
*
* The detach function deduplicates a pattern that was previously copy-pasted 3 times
* in server.ts (_doCleanupSession, exit handler, stop()).
*
* @dependencies session.ts (Session, event types), sse-events.ts, types.ts
* @consumedby web/server.ts (WebServer delegates listener lifecycle here)
*
* @module web/session-listener-wiring
*/
import type {
Session,
ClaudeMessage,
BackgroundTask,
RalphTrackerState,
RalphTodoItem,
ActiveBashTool,
} from '../session.js';
import type { RalphStatusBlock, CircuitBreakerStatus } from '../types.js';
import { SseEvent } from './sse-events.js';
import { getLifecycleLog } from '../session-lifecycle-log.js';
import { fileStreamManager } from '../file-stream-manager.js';
/** Stored listener references for session cleanup (prevents memory leaks) */
export interface SessionListenerRefs {
terminal: (data: string) => void;
clearTerminal: () => void;
needsRefresh: () => void;
message: (msg: ClaudeMessage) => void;
error: (error: string) => void;
completion: (result: string, cost: number) => void;
exit: (code: number | null) => void;
working: () => void;
idle: () => void;
taskCreated: (task: BackgroundTask) => void;
taskUpdated: (task: BackgroundTask) => void;
taskCompleted: (task: BackgroundTask) => void;
taskFailed: (task: BackgroundTask, error: string) => void;
autoClear: (data: { tokens: number; threshold: number }) => void;
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void;
cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => void;
ralphLoopUpdate: (state: RalphTrackerState) => void;
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
ralphCompletionDetected: (phrase: string) => void;
ralphStatusBlockDetected: (block: RalphStatusBlock) => void;
ralphCircuitBreakerUpdate: (status: CircuitBreakerStatus) => void;
ralphExitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => void;
bashToolStart: (tool: ActiveBashTool) => void;
bashToolEnd: (tool: ActiveBashTool) => void;
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
}
/** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */
export interface SessionListenerDeps {
broadcast(event: string, data: unknown): void;
batchTerminalData(sessionId: string, data: string): void;
batchTaskUpdate(sessionId: string, task: BackgroundTask): void;
broadcastSessionStateDebounced(sessionId: string): void;
sendPushNotifications(event: string, data: Record<string, unknown>): void;
persistSessionState(session: Session): void;
getSessionStateWithRespawn(session: Session): unknown;
getRunSummaryTracker(sessionId: string): import('../run-summary.js').RunSummaryTracker | undefined;
stopTranscriptWatcher(sessionId: string): void;
cleanupSessionBatches(sessionId: string): void;
cancelPersistDebounce(sessionId: string): void;
removeRunSummaryTracker(sessionId: string): void;
removeSessionListenerRefs(sessionId: string): void;
cleanupRespawnOnExit(sessionId: string): void;
getStore(): import('../state-store.js').StateStore;
}
/**
* Creates all 25 session listener handlers, capturing dependencies via closure.
* Call `attachSessionListeners()` after to wire them to the session.
*/
export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs {
return {
// ─── Terminal Output ─────────────────────────────────────
/** Batches PTY output → broadcasts `session:terminal` at 16-50ms intervals */
terminal: (data) => {
deps.batchTerminalData(session.id, data);
},
/** Broadcasts `session:clearTerminal` — tells clients to wipe their xterm buffer (after mux attach) */
clearTerminal: () => {
deps.broadcast(SseEvent.SessionClearTerminal, { id: session.id });
},
/** Broadcasts `session:needsRefresh` — tells clients to reload buffer */
needsRefresh: () => {
deps.broadcast(SseEvent.SessionNeedsRefresh, { id: session.id });
},
// ─── Session Messages & Errors ──────────────────────────
/** Broadcasts `session:message` — structured Claude JSON messages (assistant, tool_use, etc.) */
message: (msg: ClaudeMessage) => {
deps.broadcast(SseEvent.SessionMessage, { id: session.id, message: msg });
},
/** Broadcasts `session:error` + sends push notification */
error: (error) => {
deps.broadcast(SseEvent.SessionError, { id: session.id, error });
deps.sendPushNotifications(SseEvent.SessionError, {
sessionId: session.id,
sessionName: session.name,
error: String(error),
});
const tracker = deps.getRunSummaryTracker(session.id);
if (tracker) tracker.recordError('Session error', String(error));
},
/** Broadcasts `session:completion` + `session:updated` — prompt finished, persists state */
completion: (result, cost) => {
deps.broadcast(SseEvent.SessionCompletion, { id: session.id, result, cost });
deps.broadcast(SseEvent.SessionUpdated, deps.getSessionStateWithRespawn(session));
deps.persistSessionState(session);
const tracker = deps.getRunSummaryTracker(session.id);
if (tracker) tracker.recordTokens(session.inputTokens, session.outputTokens);
},
// ─── Session Lifecycle ──────────────────────────────────
/** Broadcasts `session:exit` + `session:updated` — PTY process exited; cleans up respawn, timers, listeners */
exit: (code) => {
getLifecycleLog().log({
event: 'exit',
sessionId: session.id,
name: session.name,
exitCode: code,
});
// Wrap in try/catch to ensure cleanup always happens
try {
deps.broadcast(SseEvent.SessionExit, { id: session.id, code });
deps.broadcast(SseEvent.SessionUpdated, deps.getSessionStateWithRespawn(session));
deps.persistSessionState(session);
} catch (err) {
console.error(`[Server] Error broadcasting session exit for ${session.id}:`, err);
}
// Always clean up respawn controller, even if broadcast failed
try {
deps.cleanupRespawnOnExit(session.id);
} catch (err) {
console.error(`[Server] Error cleaning up respawn controller for ${session.id}:`, err);
}
// Clean up per-session resources that are stale after PTY exit.
try {
// Transcript watcher is tied to the specific PTY run
deps.stopTranscriptWatcher(session.id);
// Finalize run summary tracker
deps.removeRunSummaryTracker(session.id);
// Flush/clear terminal batching state (no more output coming)
deps.cleanupSessionBatches(session.id);
// Clear pending persist-debounce timer
deps.cancelPersistDebounce(session.id);
// Close any active file streams
fileStreamManager.closeSessionStreams(session.id);
// Remove stored listener refs to break closure references (prevents memory leak).
deps.removeSessionListenerRefs(session.id);
} catch (err) {
console.error(`[Server] Error cleaning up session resources on exit for ${session.id}:`, err);
}
},
// ─── Activity State ─────────────────────────────────────
/** Broadcasts `session:working` — Claude started processing */
working: () => {
deps.broadcast(SseEvent.SessionWorking, { id: session.id });
const tracker = deps.getRunSummaryTracker(session.id);
if (tracker) {
tracker.recordWorking();
tracker.recordTokens(session.inputTokens, session.outputTokens);
}
},
/** Broadcasts `session:idle` — Claude finished processing, waiting for input */
idle: () => {
deps.broadcast(SseEvent.SessionIdle, { id: session.id });
deps.broadcastSessionStateDebounced(session.id);
const tracker = deps.getRunSummaryTracker(session.id);
if (tracker) {
tracker.recordIdle();
tracker.recordTokens(session.inputTokens, session.outputTokens);
}
},
// ─── Background Task Events ──────────────────────────────
/** Broadcasts `task:created` — new background task discovered */
taskCreated: (task: BackgroundTask) => {
deps.broadcast(SseEvent.TaskCreated, { sessionId: session.id, task });
deps.broadcastSessionStateDebounced(session.id);
},
/** Batched broadcast of `task:updated` — high-frequency progress updates */
taskUpdated: (task: BackgroundTask) => {
deps.batchTaskUpdate(session.id, task);
},
/** Broadcasts `task:completed` — background task finished successfully */
taskCompleted: (task: BackgroundTask) => {
deps.broadcast(SseEvent.TaskCompleted, { sessionId: session.id, task });
deps.broadcastSessionStateDebounced(session.id);
},
/** Broadcasts `task:failed` — background task errored */
taskFailed: (task: BackgroundTask, error: string) => {
deps.broadcast(SseEvent.TaskFailed, { sessionId: session.id, task, error });
deps.broadcastSessionStateDebounced(session.id);
},
// ─── Auto-Operations ────────────────────────────────────
/** Broadcasts `session:autoClear` — context window auto-cleared at token threshold */
autoClear: (data: { tokens: number; threshold: number }) => {
deps.broadcast(SseEvent.SessionAutoClear, { sessionId: session.id, ...data });
deps.broadcastSessionStateDebounced(session.id);
const tracker = deps.getRunSummaryTracker(session.id);
if (tracker) tracker.recordAutoClear(data.tokens, data.threshold);
},
/** Broadcasts `session:autoCompact` — context window auto-compacted at token threshold */
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => {
deps.broadcast(SseEvent.SessionAutoCompact, { sessionId: session.id, ...data });
deps.broadcastSessionStateDebounced(session.id);
const tracker = deps.getRunSummaryTracker(session.id);
if (tracker) tracker.recordAutoCompact(data.tokens, data.threshold);
},
// ─── CLI Info ────────────────────────────────────────────
/** Broadcasts `session:cliInfo` — Claude Code version, model, account type parsed from terminal */
cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => {
deps.broadcast(SseEvent.SessionCliInfo, { sessionId: session.id, ...data });
deps.broadcastSessionStateDebounced(session.id);
},
// ─── Ralph Tracking Events ──────────────────────────────
/** Broadcasts `session:ralphLoopUpdate` — Ralph tracker loop state changed (iteration, phase) */
ralphLoopUpdate: (state: RalphTrackerState) => {
deps.broadcast(SseEvent.SessionRalphLoopUpdate, { sessionId: session.id, state });
deps.getStore().updateRalphState(session.id, { loop: state });
},
/** Broadcasts `session:ralphTodoUpdate` — todo items added, completed, or modified */
ralphTodoUpdate: (todos: RalphTodoItem[]) => {
deps.broadcast(SseEvent.SessionRalphTodoUpdate, { sessionId: session.id, todos });
deps.getStore().updateRalphState(session.id, { todos });
},
/** Broadcasts `session:ralphCompletionDetected` + push notification — completion phrase matched */
ralphCompletionDetected: (phrase: string) => {
deps.broadcast(SseEvent.SessionRalphCompletionDetected, { sessionId: session.id, phrase });
deps.sendPushNotifications(SseEvent.SessionRalphCompletionDetected, {
sessionId: session.id,
sessionName: session.name,
phrase,
});
const tracker = deps.getRunSummaryTracker(session.id);
if (tracker) tracker.recordRalphCompletion(phrase);
},
/** Broadcasts `session:ralphStatusUpdate` — RALPH_STATUS block parsed from output */
ralphStatusBlockDetected: (block: RalphStatusBlock) => {
deps.broadcast(SseEvent.SessionRalphStatusUpdate, { sessionId: session.id, block });
const tracker = deps.getRunSummaryTracker(session.id);
if (tracker) {
tracker.addEvent(
block.status === 'BLOCKED' ? 'warning' : 'idle_detected',
block.status === 'BLOCKED' ? 'warning' : 'info',
`Ralph Status: ${block.status}`,
`Tasks: ${block.tasksCompletedThisLoop}, Files: ${block.filesModified}, Tests: ${block.testsStatus}`
);
}
},
/** Broadcasts `session:circuitBreakerUpdate` — circuit breaker state changed (CLOSED/HALF_OPEN/OPEN) */
ralphCircuitBreakerUpdate: (status: CircuitBreakerStatus) => {
deps.broadcast(SseEvent.SessionCircuitBreakerUpdate, { sessionId: session.id, status });
const tracker = deps.getRunSummaryTracker(session.id);
if (tracker && status.state === 'OPEN') {
tracker.addEvent('warning', 'warning', 'Circuit Breaker Opened', status.reason);
}
},
/** Broadcasts `session:exitGateMet` — all completion indicators met, ready to exit */
ralphExitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => {
deps.broadcast(SseEvent.SessionExitGateMet, { sessionId: session.id, ...data });
const tracker = deps.getRunSummaryTracker(session.id);
if (tracker) {
tracker.addEvent(
'ralph_completion',
'success',
'Exit Gate Met',
`Indicators: ${data.completionIndicators}, EXIT_SIGNAL: ${data.exitSignal}`
);
}
},
// ─── Bash Tool Tracking ────────────────────────────────
/** Broadcasts `session:bashToolStart` — bash tool invocation started */
bashToolStart: (tool: ActiveBashTool) => {
deps.broadcast(SseEvent.SessionBashToolStart, { sessionId: session.id, tool });
},
/** Broadcasts `session:bashToolEnd` — bash tool invocation completed */
bashToolEnd: (tool: ActiveBashTool) => {
deps.broadcast(SseEvent.SessionBashToolEnd, { sessionId: session.id, tool });
},
/** Broadcasts `session:bashToolsUpdate` — full active bash tools list refreshed */
bashToolsUpdate: (tools: ActiveBashTool[]) => {
deps.broadcast(SseEvent.SessionBashToolsUpdate, { sessionId: session.id, tools });
},
};
}
/** Attach all listeners to a session. */
export function attachSessionListeners(session: Session, refs: SessionListenerRefs): void {
session.on('terminal', refs.terminal);
session.on('clearTerminal', refs.clearTerminal);
session.on('needsRefresh', refs.needsRefresh);
session.on('message', refs.message);
session.on('error', refs.error);
session.on('completion', refs.completion);
session.on('exit', refs.exit);
session.on('working', refs.working);
session.on('idle', refs.idle);
session.on('taskCreated', refs.taskCreated);
session.on('taskUpdated', refs.taskUpdated);
session.on('taskCompleted', refs.taskCompleted);
session.on('taskFailed', refs.taskFailed);
session.on('autoClear', refs.autoClear);
session.on('autoCompact', refs.autoCompact);
session.on('cliInfoUpdated', refs.cliInfoUpdated);
session.on('ralphLoopUpdate', refs.ralphLoopUpdate);
session.on('ralphTodoUpdate', refs.ralphTodoUpdate);
session.on('ralphCompletionDetected', refs.ralphCompletionDetected);
session.on('ralphStatusBlockDetected', refs.ralphStatusBlockDetected);
session.on('ralphCircuitBreakerUpdate', refs.ralphCircuitBreakerUpdate);
session.on('ralphExitGateMet', refs.ralphExitGateMet);
session.on('bashToolStart', refs.bashToolStart);
session.on('bashToolEnd', refs.bashToolEnd);
session.on('bashToolsUpdate', refs.bashToolsUpdate);
}
/** Detach all listeners from a session (prevents memory leaks from closure references). */
export function detachSessionListeners(session: Session, refs: SessionListenerRefs): void {
session.off('terminal', refs.terminal);
session.off('clearTerminal', refs.clearTerminal);
session.off('needsRefresh', refs.needsRefresh);
session.off('message', refs.message);
session.off('error', refs.error);
session.off('completion', refs.completion);
session.off('exit', refs.exit);
session.off('working', refs.working);
session.off('idle', refs.idle);
session.off('taskCreated', refs.taskCreated);
session.off('taskUpdated', refs.taskUpdated);
session.off('taskCompleted', refs.taskCompleted);
session.off('taskFailed', refs.taskFailed);
session.off('autoClear', refs.autoClear);
session.off('autoCompact', refs.autoCompact);
session.off('cliInfoUpdated', refs.cliInfoUpdated);
session.off('ralphLoopUpdate', refs.ralphLoopUpdate);
session.off('ralphTodoUpdate', refs.ralphTodoUpdate);
session.off('ralphCompletionDetected', refs.ralphCompletionDetected);
session.off('ralphStatusBlockDetected', refs.ralphStatusBlockDetected);
session.off('ralphCircuitBreakerUpdate', refs.ralphCircuitBreakerUpdate);
session.off('ralphExitGateMet', refs.ralphExitGateMet);
session.off('bashToolStart', refs.bashToolStart);
session.off('bashToolEnd', refs.bashToolEnd);
session.off('bashToolsUpdate', refs.bashToolsUpdate);
}
+488
View File
@@ -0,0 +1,488 @@
/**
* @fileoverview SSE stream manager — owns all SSE client state, broadcasting, and event batching.
*
* Extracted from server.ts for modularity. Handles:
* - SSE client connection tracking with subscription filtering
* - Backpressure-aware message delivery
* - Terminal data batching with adaptive intervals (16-50ms for 60fps)
* - Task update and session state batching
* - Dead client cleanup and keepalive
* - Cloudflare tunnel padding for proxy buffer flushing
*
* @dependencies CleanupManager (managed timers), config/server-timing (constants)
* @consumedby web/server.ts (WebServer delegates all SSE operations here)
*
* @module web/sse-stream-manager
*/
import type { FastifyReply } from 'fastify';
import type { BackgroundTask } from '../session.js';
import { CleanupManager, StaleExpirationMap } from '../utils/index.js';
import { SseEvent } from './sse-events.js';
import {
TERMINAL_BATCH_INTERVAL,
TASK_UPDATE_BATCH_INTERVAL,
STATE_UPDATE_DEBOUNCE_INTERVAL,
BATCH_FLUSH_THRESHOLD,
SSE_PADDING_SIZE,
INACTIVITY_TIMEOUT_MS,
} from '../config/server-timing.js';
// SSE padding for Cloudflare tunnel buffer flushing.
// Cloudflare quick tunnels buffer small SSE responses, causing lag for real-time events.
// Appending SSE comment padding (ignored by EventSource) forces the proxy to flush.
// Pre-computed once at startup to avoid repeated string allocation.
const SSE_PADDING = ':' + 'p'.repeat(SSE_PADDING_SIZE) + '\n';
/** Dependencies injected by WebServer — keeps SseStreamManager decoupled from session/respawn state. */
export interface SseStreamManagerDeps {
/** Get session state with respawn info for session:updated broadcasts */
getSessionStateWithRespawn(sessionId: string): unknown;
}
export class SseStreamManager {
// ─── SSE Client Tracking ────────────────────────────────
/**
* SSE clients mapped to their session subscription filter.
* Value is a Set of session IDs the client wants events for,
* or `null` meaning "receive all events" (backwards-compatible default).
*/
private sseClients: Map<FastifyReply, Set<string> | null> = new Map();
/** SSE clients connecting from non-localhost (i.e. through tunnel) */
private remoteSseClients: Set<FastifyReply> = new Set();
/** Clients with backpressure — skip writes until 'drain' fires */
private backpressuredClients: Set<FastifyReply> = new Set();
// ─── Tunnel State ───────────────────────────────────────
/** Cached tunnel active state — updated on TunnelStarted/TunnelStopped to avoid getUrl() on every broadcast */
private _isTunnelActive: boolean = false;
// ─── Terminal Batching ──────────────────────────────────
private terminalBatches: Map<string, string[]> = new Map();
private terminalBatchSizes: Map<string, number> = new Map(); // Running total avoids O(n) reduce per push
private terminalBatchTimers: Map<string, NodeJS.Timeout> = new Map(); // Per-session timers (staggered flushes)
// Adaptive batching: track rapid events to extend batch window (per-session)
// StaleExpirationMap auto-cleans entries for sessions that stop generating output
private lastTerminalEventTime: StaleExpirationMap<string, number>;
// ─── Event Batching ─────────────────────────────────────
private taskUpdateBatches: Map<string, { sessionId: string; task: BackgroundTask }> = new Map();
private taskUpdateBatchTimerId: string | null = null;
// State update batching (reduce expensive toDetailedState() serialization)
private stateUpdatePending: Set<string> = new Set();
private stateUpdateTimerId: string | null = null;
// ─── Lifecycle ──────────────────────────────────────────
private _isStopping: boolean = false;
constructor(
private deps: SseStreamManagerDeps,
private cleanup: CleanupManager
) {
this.lastTerminalEventTime = new StaleExpirationMap({
ttlMs: INACTIVITY_TIMEOUT_MS, // 5 minutes - auto-expire stale session timing data
refreshOnGet: false, // Don't refresh on reads, only on explicit sets
});
}
// ========== SSE Connection Management ==========
get clientCount(): number {
return this.sseClients.size;
}
get remoteClientCount(): number {
return this.remoteSseClients.size;
}
get isTunnelActive(): boolean {
return this._isTunnelActive;
}
setTunnelActive(active: boolean): void {
this._isTunnelActive = active;
}
addClient(reply: FastifyReply, sessionFilter: Set<string> | null, isRemote: boolean): void {
this.sseClients.set(reply, sessionFilter);
if (isRemote) {
this.remoteSseClients.add(reply);
}
}
removeClient(reply: FastifyReply): void {
this.sseClients.delete(reply);
this.remoteSseClients.delete(reply);
this.backpressuredClients.delete(reply);
}
/** Send a single SSE event to a specific client. */
sendSSE(reply: FastifyReply, event: string, data: unknown): void {
try {
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
} catch {
this.sseClients.delete(reply);
this.remoteSseClients.delete(reply);
}
}
/** Send pre-formatted tunnel padding to a specific client. */
sendPadding(reply: FastifyReply): void {
if (!this._isTunnelActive) return;
try {
reply.raw.write(SSE_PADDING);
} catch {
/* client gone */
}
}
// Optimized: send pre-formatted SSE message to a client
// Returns false if client is backpressured or dead
private sendSSEPreformatted(reply: FastifyReply, message: string): void {
// Skip backpressured clients to prevent unbounded memory growth.
// Terminal data dropped here is recovered via session:needsRefresh on drain.
if (this.backpressuredClients.has(reply)) return;
try {
const ok = reply.raw.write(message);
if (!ok) {
// Buffer is full — mark as backpressured, resume on drain
this.backpressuredClients.add(reply);
reply.raw.once('drain', () => {
this.backpressuredClients.delete(reply);
// Client may have missed terminal data during backpressure.
// Tell it to reload the active session's buffer to recover.
try {
const drainPadding = this._isTunnelActive ? SSE_PADDING : '';
reply.raw.write(`event: ${SseEvent.SessionNeedsRefresh}\ndata: {}\n\n${drainPadding}`);
} catch {
/* client gone */
}
});
}
} catch {
this.sseClients.delete(reply);
this.remoteSseClients.delete(reply);
this.backpressuredClients.delete(reply);
}
}
// ========== Broadcasting ==========
broadcast(event: string, data: unknown): void {
// Skip serialization entirely when no clients are listening
if (this.sseClients.size === 0) return;
// Performance optimization: serialize JSON once for all clients.
// Only append Cloudflare tunnel padding for latency-sensitive events —
// Recovery events need immediate proxy flush; low-frequency metadata events
// (session:created, ralph:*, respawn:*, etc.) don't need padding.
// Note: session:terminal has its own padding in flushSessionTerminalBatch().
const needsPadding = this._isTunnelActive && event === SseEvent.SessionNeedsRefresh;
const padding = needsPadding ? SSE_PADDING : '';
let message: string;
try {
message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` + padding;
} catch (err) {
// Handle circular references or non-serializable values
console.error(`[Server] Failed to serialize SSE event "${event}":`, err);
return;
}
// Extract sessionId from event data for subscription filtering.
const eventSessionId = this.extractSessionId(event, data);
for (const [client, filter] of this.sseClients) {
// No filter (null) = receive everything. Otherwise, skip if event is
// session-scoped and the session isn't in the client's subscription set.
if (filter && eventSessionId && !filter.has(eventSessionId)) continue;
this.sendSSEPreformatted(client, message);
}
}
/**
* Extract the session ID from an event's data payload for subscription filtering.
* Returns the sessionId string if the event is session-scoped, or null for global events.
*/
private extractSessionId(event: string, data: unknown): string | null {
if (data == null || typeof data !== 'object') return null;
const record = data as Record<string, unknown>;
// Most session-scoped events use `sessionId`
if (typeof record.sessionId === 'string') return record.sessionId;
// Session lifecycle events (session:*) use `id` from the session state object
if (typeof record.id === 'string' && event.startsWith('session:')) return record.id;
// No session ID found — treat as global event (sent to all clients)
return null;
}
// ========== Terminal Data Batching ==========
// Batch terminal data for better performance (60fps)
// Uses per-session timers with adaptive intervals to prevent thundering herd:
// each session flushes independently rather than all sessions flushing in one burst.
batchTerminalData(sessionId: string, data: string): void {
// Skip if server is stopping
if (this._isStopping) return;
let chunks = this.terminalBatches.get(sessionId);
if (!chunks) {
chunks = [];
this.terminalBatches.set(sessionId, chunks);
}
chunks.push(data);
const prevSize = this.terminalBatchSizes.get(sessionId) ?? 0;
const totalLength = prevSize + data.length;
this.terminalBatchSizes.set(sessionId, totalLength);
// Adaptive batching: detect rapid events and extend batch window (per-session)
const now = Date.now();
const lastEvent = this.lastTerminalEventTime.get(sessionId) ?? 0;
const eventGap = now - lastEvent;
this.lastTerminalEventTime.set(sessionId, now);
// Adjust batch interval based on event frequency (per-session)
// Rapid events (<10ms gap) = 50ms batch, moderate (<20ms) = 32ms, else 16ms
let sessionInterval: number;
if (eventGap > 0 && eventGap < 10) {
sessionInterval = 50;
} else if (eventGap > 0 && eventGap < 20) {
sessionInterval = 32;
} else {
sessionInterval = TERMINAL_BATCH_INTERVAL;
}
// Flush immediately if batch is large for responsiveness
if (totalLength > BATCH_FLUSH_THRESHOLD) {
const existingTimer = this.terminalBatchTimers.get(sessionId);
if (existingTimer) {
clearTimeout(existingTimer);
this.terminalBatchTimers.delete(sessionId);
}
this.flushSessionTerminalBatch(sessionId);
return;
}
// Start per-session batch timer if not already running
// Each session flushes independently — prevents one busy session from
// forcing all sessions to flush at its rate (thundering herd)
if (!this.terminalBatchTimers.has(sessionId)) {
this.terminalBatchTimers.set(
sessionId,
setTimeout(() => {
this.terminalBatchTimers.delete(sessionId);
this.flushSessionTerminalBatch(sessionId);
}, sessionInterval)
);
}
}
/** Flush a single session's batched terminal data */
private flushSessionTerminalBatch(sessionId: string): void {
if (this._isStopping) {
this.terminalBatches.delete(sessionId);
this.terminalBatchSizes.delete(sessionId);
return;
}
const chunks = this.terminalBatches.get(sessionId);
if (chunks && chunks.length > 0) {
// Join chunks only at flush time (avoids O(n^2) string concatenation in batchTerminalData)
const data = chunks.join('');
// Wrap batched output in DEC 2026 synchronized output markers so xterm.js
// renders the entire batch atomically. Ink spinner frames (cursor-up + redraw)
// do NOT emit their own 2026 markers, so without this wrapper each partial
// cursor update renders individually, causing visible flicker.
// xterm.js 6.0+ handles DEC 2026 natively: it buffers everything between
// 2026h/2026l and renders in one pass.
const syncData = '\x1b[?2026h' + data + '\x1b[?2026l';
// Fast path: build SSE message directly without JSON.stringify on wrapper object.
// Only the terminal data string needs escaping; sessionId is a UUID (safe to template).
const escapedData = JSON.stringify(syncData);
// Append tunnel padding for immediate Cloudflare proxy flush —
// terminal data is high-frequency and latency-sensitive.
const padding = this._isTunnelActive ? SSE_PADDING : '';
const message = `event: session:terminal\ndata: {"id":"${sessionId}","data":${escapedData}}\n\n` + padding;
for (const [client, filter] of this.sseClients) {
// Skip clients that have a session filter and aren't subscribed to this session
if (filter && !filter.has(sessionId)) continue;
this.sendSSEPreformatted(client, message);
}
}
this.terminalBatches.delete(sessionId);
this.terminalBatchSizes.delete(sessionId);
}
// ========== Task Update Batching ==========
// Batch task:updated events at 100ms - only send latest update per task
// Key is sessionId:taskId to avoid collisions when multiple tasks update concurrently
batchTaskUpdate(sessionId: string, task: BackgroundTask): void {
// Skip if server is stopping
if (this._isStopping) return;
// Use composite key to avoid losing updates when multiple tasks update in same batch window
const key = `${sessionId}:${task.id}`;
this.taskUpdateBatches.set(key, { sessionId, task });
if (!this.taskUpdateBatchTimerId) {
this.taskUpdateBatchTimerId = this.cleanup.setTimeout(
() => {
this.taskUpdateBatchTimerId = null;
this.flushTaskUpdateBatches();
},
TASK_UPDATE_BATCH_INTERVAL,
{ description: 'task update batch flush' }
);
}
}
private flushTaskUpdateBatches(): void {
// Skip if server is stopping (timer may have been queued before stop() was called)
if (this._isStopping) {
this.taskUpdateBatches.clear();
return;
}
for (const [, { sessionId, task }] of this.taskUpdateBatches) {
this.broadcast(SseEvent.TaskUpdated, { sessionId, task });
}
this.taskUpdateBatches.clear();
}
// ========== Session State Batching ==========
/**
* Debounce expensive session:updated broadcasts.
* Instead of calling toDetailedState() on every event, batch requests
* and only serialize once per STATE_UPDATE_DEBOUNCE_INTERVAL.
*/
broadcastSessionStateDebounced(sessionId: string): void {
// Skip if server is stopping
if (this._isStopping) return;
this.stateUpdatePending.add(sessionId);
if (!this.stateUpdateTimerId) {
this.stateUpdateTimerId = this.cleanup.setTimeout(
() => {
this.stateUpdateTimerId = null;
this.flushStateUpdates();
},
STATE_UPDATE_DEBOUNCE_INTERVAL,
{ description: 'state update debounce flush' }
);
}
}
private flushStateUpdates(): void {
// Skip if server is stopping (timer may have been queued before stop() was called)
if (this._isStopping) {
this.stateUpdatePending.clear();
return;
}
for (const sessionId of this.stateUpdatePending) {
// Single expensive serialization per batch interval
const state = this.deps.getSessionStateWithRespawn(sessionId);
if (state) {
this.broadcast(SseEvent.SessionUpdated, state);
}
}
this.stateUpdatePending.clear();
}
// ========== Client Health ==========
/**
* Clean up dead SSE clients and send keep-alive comments.
* Keep-alive prevents proxy/load-balancer timeouts on idle connections.
* Dead client cleanup prevents memory leaks from abruptly terminated connections.
*/
cleanupDeadClients(): void {
const deadClients: FastifyReply[] = [];
for (const [client] of this.sseClients) {
try {
// Check if the underlying socket is still writable
const socket = client.raw.socket;
if (!socket || socket.destroyed || !socket.writable) {
deadClients.push(client);
} else {
// Send SSE comment as keep-alive. Only add padding when tunnel is
// active — it flushes Cloudflare proxy buffers but wastes bandwidth
// for direct/Tailscale connections.
const ka = this._isTunnelActive ? ':keepalive\n' + SSE_PADDING : ':keepalive\n\n';
client.raw.write(ka);
}
} catch {
// Error accessing socket means client is dead
deadClients.push(client);
}
}
// Remove dead clients
for (const client of deadClients) {
this.sseClients.delete(client);
this.remoteSseClients.delete(client);
this.backpressuredClients.delete(client);
}
if (deadClients.length > 0) {
console.log(`[Server] Cleaned up ${deadClients.length} dead SSE client(s)`);
}
}
// ========== Session Cleanup ==========
/** Clean up all batching state for a session (call on session exit or deletion). */
cleanupSessionBatches(sessionId: string): void {
this.terminalBatches.delete(sessionId);
this.terminalBatchSizes.delete(sessionId);
const batchTimer = this.terminalBatchTimers.get(sessionId);
if (batchTimer) {
clearTimeout(batchTimer);
this.terminalBatchTimers.delete(sessionId);
}
this.taskUpdateBatches.delete(sessionId);
this.stateUpdatePending.delete(sessionId);
this.lastTerminalEventTime.delete(sessionId);
}
// ========== Lifecycle ==========
setStopping(): void {
this._isStopping = true;
}
/** Graceful shutdown: notify clients, close connections, clear all state. */
stop(): void {
this._isStopping = true;
// Gracefully close all SSE connections before clearing
for (const [client] of this.sseClients) {
try {
// Send a final event to notify clients of shutdown
this.sendSSE(client, 'server:shutdown', { reason: 'Server stopping' });
client.raw.end();
} catch {
// Client may already be disconnected
}
}
this.sseClients.clear();
this.remoteSseClients.clear();
this.backpressuredClients.clear();
// Clear per-session batch timers
for (const timer of this.terminalBatchTimers.values()) {
clearTimeout(timer);
}
this.terminalBatchTimers.clear();
this.terminalBatches.clear();
this.terminalBatchSizes.clear();
this.taskUpdateBatches.clear();
this.stateUpdatePending.clear();
// Dispose StaleExpirationMap (stops internal cleanup timer)
this.lastTerminalEventTime.dispose();
}
}
+30
View File
@@ -366,6 +366,36 @@ describe('system-routes', () => {
expect(body.success).toBe(false);
});
it('saves lastUsedCase as partial update without overwriting other settings', async () => {
mockedReadFile.mockResolvedValue(
JSON.stringify({ showCost: true, showTokenCount: false, subagentTrackingEnabled: true }) as never
);
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { lastUsedCase: 'my-test-case' },
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).success).toBe(true);
const writtenContent = JSON.parse(mockedWriteFile.mock.calls[0][1] as string);
expect(writtenContent.lastUsedCase).toBe('my-test-case');
expect(writtenContent.showCost).toBe(true);
expect(writtenContent.showTokenCount).toBe(false);
expect(writtenContent.subagentTrackingEnabled).toBe(true);
});
it('rejects settings with modelConfig (strict schema prevents full-object PUT)', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { lastUsedCase: 'test', modelConfig: { model: 'something' } },
});
// Fastify rejects unknown fields at schema validation level (400) before handler runs
expect(res.statusCode).toBe(400);
});
it('rejects non-object body', async () => {
const res = await harness.app.inject({
method: 'PUT',