Compare commits

...
Author SHA1 Message Date
arkon 64b8ea30b2 chore: version packages 2026-03-31 03:10:22 +02:00
Ark0N 5743af3339 Merge pull request #52 from TeigenZhang/feat/default-model-support
Thanks for the contribution @TeigenZhang! Clean, well-scoped change — applied consistently across all session creation paths. 🎉
2026-03-30 19:19:04 +02:00
Teigen cd49171bbc feat: support "Default (CLI default)" option for model selection
Allow users to leave the default model unset, so sessions use whatever
the Claude CLI defaults to rather than forcing a specific model.

- Add empty-value "Default (CLI default)" option to the model dropdown
- Treat empty string as undefined when passing model to Session
- Apply consistently across session creation, quick-start, and Ralph
2026-03-30 16:44:00 +08:00
arkon 0f57342b10 chore: version packages 2026-03-29 05:10:16 +02:00
arkonandClaude Opus 4.6 e1f0ac993a fix: default new sessions to opus[1m] (1M context) instead of opus (200k)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 05:09:24 +02:00
arkon a84ef52992 chore: version packages 2026-03-28 16:49:55 +01:00
arkonandClaude Opus 4.6 692c894760 fix: correct process tree detection and prevent timer starvation
1. Rewrote getActiveChildProcesses() to use a single `ps --ppid` call
   instead of two-level pgrep. The pane PID is typically claude itself
   (bash exec'd into it), not a bash wrapper — so direct children of
   pane_pid ARE the tool processes.

2. Added timer restart in tryStartAiCheck() when skipping due to child
   processes. Without this, the pre-filter and no-output timers (both
   one-shot) would never fire again, permanently stalling idle detection
   for sessions with silent long-running processes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 05:14:39 +01:00
arkonandClaude Opus 4.6 ad0acb6d58 feat: detect active child processes to prevent false idle during running tools
When Claude Code spawns bash tools (test suites, builds, servers), the
respawn controller could falsely detect idle if terminal output paused.
Now checks the process tree for active children of the Claude process
before triggering AI idle checks or confirming idle state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 04:34:53 +01:00
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
41 changed files with 3386 additions and 2886 deletions

No files matched your search

+39
View File
@@ -1,5 +1,44 @@
# aicodeman # aicodeman
## 0.5.7
### Patch Changes
- feat: support "Default (CLI default)" option for model selection. Adds a new empty-value option to the model dropdown that defers to the CLI's own default model instead of forcing a specific model. Ensures empty defaultModel values are treated as undefined when passed to session creation and Ralph loop start, preventing empty strings from being sent as model flags.
## 0.5.6
### Patch Changes
- fix: default new sessions to opus[1m] (1M context window) instead of plain opus (200k context)
## 0.5.5
### Patch Changes
- Add 1M Opus context quick setting — per-case and global toggle that writes `model: "opus[1m]"` to `.claude/settings.local.json` when creating new sessions. Fix mobile layout: banners (respawn, timer, orchestrator) between header and main content now visible by switching from margin-top on `.main` to padding-top on `.app`. Add tablet-optimized respawn banner styles and mobile phone banner refinements.
## 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 ## 0.5.2
### Patch Changes ### 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 | | 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` | | Type check | `tsc --noEmit` |
| Lint | `npm run lint` (fix: `npm run lint:fix`) | | Lint | `npm run lint` (fix: `npm run lint:fix`) |
| Format | `npm run format` (check: `npm run format:check`) | | 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` 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` 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.7 (must match `package.json`)
## Project Overview ## 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 start | `npm run start` |
| Production logs | `journalctl --user -u codeman-web -f` | | 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/**`. **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 ### 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 ### API Routes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aicodeman", "name": "aicodeman",
"version": "0.5.2", "version": "0.5.7",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
+3 -4
View File
@@ -30,6 +30,7 @@ import { join } from 'node:path';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { getAugmentedPath, ANSI_ESCAPE_PATTERN_SIMPLE } from './utils/index.js'; import { getAugmentedPath, ANSI_ESCAPE_PATTERN_SIMPLE } from './utils/index.js';
import { AI_CHECK_MAX_BACKOFF_MS } from './config/ai-defaults.js'; import { AI_CHECK_MAX_BACKOFF_MS } from './config/ai-defaults.js';
import { getErrorMessage } from './types.js';
// ========== Security Validation ========== // ========== Security Validation ==========
@@ -293,7 +294,7 @@ export abstract class AiCheckerBase<
this.emit('checkCompleted', result); this.emit('checkCompleted', result);
return result; return result;
} catch (err) { } catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err); const errorMsg = getErrorMessage(err);
this.handleError(errorMsg); this.handleError(errorMsg);
const result = this.createErrorResult(errorMsg, Date.now() - this.checkStartTime); const result = this.createErrorResult(errorMsg, Date.now() - this.checkStartTime);
this.emit('checkFailed', errorMsg); this.emit('checkFailed', errorMsg);
@@ -412,9 +413,7 @@ export abstract class AiCheckerBase<
}); });
muxProcess.unref(); muxProcess.unref();
} catch (err) { } catch (err) {
throw new Error( throw new Error(`Failed to spawn ${this.checkDescription} tmux session: ${getErrorMessage(err)}`);
`Failed to spawn ${this.checkDescription} tmux session: ${err instanceof Error ? err.message : String(err)}`
);
} }
// Poll the temp file for completion // 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. * Process a single pre-stripped line of terminal output.
*/ */
private processCleanLine(cleanLine: string): void { 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); const startMatch = cleanLine.match(BASH_TOOL_START_PATTERN);
if (startMatch) { if (!startMatch) return false;
const command = startMatch[1];
const timeout = startMatch[2]?.trim();
// Check if this is a file-viewing command const command = startMatch[1];
if (this.isFileViewerCommand(command)) { const timeout = startMatch[2]?.trim();
const filePaths = this.extractFilePaths(command);
// Skip if any file path is already tracked (cross-pattern dedup) if (!this.isFileViewerCommand(command)) return true;
if (filePaths.some((fp) => this.isFilePathTracked(fp))) {
return;
}
if (filePaths.length > 0) { const filePaths = this.extractFilePaths(command);
const tool: ActiveBashTool = {
id: uuidv4(),
command,
filePaths,
timeout,
startedAt: Date.now(),
status: 'running',
sessionId: this._sessionId,
};
// Enforce max tools limit // Skip if any file path is already tracked (cross-pattern dedup)
if (this._activeTools.size >= MAX_ACTIVE_TOOLS) { if (filePaths.some((fp) => this.isFilePathTracked(fp))) return true;
// Remove oldest tool (O(n) min-scan instead of O(n log n) sort)
let oldestKey: string | undefined; if (filePaths.length > 0) {
let oldestTime = Infinity; const tool = this._createActiveTool(command, filePaths, 'running', timeout);
for (const [key, entry] of this._activeTools) {
if (entry.startedAt < oldestTime) { // Enforce max tools limit
oldestTime = entry.startedAt; if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
oldestKey = key; // Remove oldest tool (O(n) min-scan instead of O(n log n) sort)
} let oldestKey: string | undefined;
} let oldestTime = Infinity;
if (oldestKey) { for (const [key, entry] of this._activeTools) {
this._activeTools.delete(oldestKey); 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();
} }
} if (oldestKey) {
return; this._activeTools.delete(oldestKey);
} }
// 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;
} }
this._activeTools.set(tool.id, tool); this._activeTools.set(tool.id, tool);
this._lastToolId = tool.id;
this.emit('toolStart', tool); this.emit('toolStart', tool);
this.scheduleUpdate(); 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; LOG_FILE_MENTION_PATTERN.lastIndex = 0;
let logMatch; let logMatch;
while ((logMatch = LOG_FILE_MENTION_PATTERN.exec(cleanLine)) !== null) { 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) // Skip if file path already tracked (cross-pattern dedup)
if (this.isFilePathTracked(filePath)) continue; if (this.isFilePathTracked(filePath)) continue;
const tool: ActiveBashTool = { const tool = this._createActiveTool(`View: ${filePath}`, [filePath], 'running');
id: uuidv4(),
command: `View: ${filePath}`,
filePaths: [filePath],
timeout: undefined,
startedAt: Date.now(),
status: 'running',
sessionId: this._sessionId,
};
this._activeTools.set(tool.id, tool); this._activeTools.set(tool.id, tool);
this.emit('toolStart', tool); this.emit('toolStart', tool);
this.scheduleUpdate(); this.scheduleUpdate();
// Auto-remove after 60 seconds // Auto-remove after 60 seconds
this.cleanup.setTimeout( this._scheduleAutoRemove(tool.id, 60000, 'auto-remove log file tool');
() => {
if (this._destroyed) return;
this._activeTools.delete(tool.id);
this.scheduleUpdate();
},
60000,
{ description: '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. * 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 { resolve, relative, isAbsolute } from 'node:path';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { getErrorMessage } from './types.js';
import { CLEANUP_CHECK_INTERVAL_MS, INACTIVITY_TIMEOUT_MS } from './config/server-timing.js'; import { CLEANUP_CHECK_INTERVAL_MS, INACTIVITY_TIMEOUT_MS } from './config/server-timing.js';
// ========== Configuration Constants ========== // ========== Configuration Constants ==========
@@ -172,10 +173,7 @@ export class FileStreamManager extends EventEmitter {
} }
} catch (err) { } catch (err) {
const errorCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : 'UNKNOWN'; const errorCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : 'UNKNOWN';
console.warn( console.warn(`[FileStreamManager] Failed to stat file "${absolutePath}" (${errorCode}):`, getErrorMessage(err));
`[FileStreamManager] Failed to stat file "${absolutePath}" (${errorCode}):`,
err instanceof Error ? err.message : String(err)
);
return { success: false, error: 'File not found or not accessible' }; return { success: false, error: 'File not found or not accessible' };
} }
+28
View File
@@ -116,6 +116,34 @@ export async function updateCaseEnvVars(casePath: string, envVars: Record<string
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n'); await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
} }
/**
* Updates the `model` field in .claude/settings.local.json for the given case path.
* Pass a non-empty string to set, or empty/null to remove.
*/
export async function updateCaseModel(casePath: string, model: string | null): Promise<void> {
const claudeDir = join(casePath, '.claude');
if (!existsSync(claudeDir)) {
await mkdir(claudeDir, { recursive: true });
}
const settingsPath = join(claudeDir, 'settings.local.json');
let existing: Record<string, unknown> = {};
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
existing = {};
}
if (model) {
existing.model = model;
} else {
delete existing.model;
}
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
}
/** /**
* Writes hooks config to .claude/settings.local.json in the given case path. * Writes hooks config to .claude/settings.local.json in the given case path.
* Merges with existing file content, only touching the `hooks` key. * Merges with existing file content, only touching the `hooks` key.
+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); 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; if (!orchTask) return;
orchTask.status = 'completed';
orchTask.completedAt = Date.now();
this.stats.totalTasksCompleted++;
this.persist();
this.emit('taskCompleted', orchTask); this.emit('taskCompleted', orchTask);
this.checkPhaseCompletion(); this.checkPhaseCompletion();
} }
private handleTaskFailed(queueTaskId: string, error: string): void { private handleTaskFailed(queueTaskId: string, error: string): void {
const orchTask = this.findOrchestratorTaskByQueueId(queueTaskId); const orchTask = this._finalizeTask(queueTaskId, 'failed', error);
if (!orchTask) return; if (!orchTask) return;
orchTask.status = 'failed';
orchTask.error = error;
this.stats.totalTasksFailed++;
this.persist();
this.emit('taskFailed', orchTask, error); this.emit('taskFailed', orchTask, error);
// Check if we should retry the task or fail the phase // Check if we should retry the task or fail the phase
@@ -553,19 +560,20 @@ export class OrchestratorLoop extends EventEmitter {
}, this.config.phaseTimeoutMs); }, 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 { private clearPhasePoll(): void {
if (this.phasePollTimer) { this._clearTimer('phasePollTimer', clearInterval);
clearInterval(this.phasePollTimer); this._clearTimer('phaseTimeoutTimer', clearTimeout);
this.phasePollTimer = null; this._clearTimer('postPhaseTimer', clearTimeout);
}
if (this.phaseTimeoutTimer) {
clearTimeout(this.phaseTimeoutTimer);
this.phaseTimeoutTimer = null;
}
if (this.postPhaseTimer) {
clearTimeout(this.postPhaseTimer);
this.postPhaseTimer = null;
}
} }
private pollPhaseStatus(phase: OrchestratorPhase): void { private pollPhaseStatus(phase: OrchestratorPhase): void {
+9 -3
View File
@@ -20,7 +20,13 @@
*/ */
import type { Session } from './session.js'; 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 // Constants
@@ -111,7 +117,7 @@ export class OrchestratorVerifier {
type: 'test_command', type: 'test_command',
description: `Run: ${command}`, description: `Run: ${command}`,
passed: false, passed: false,
output: err instanceof Error ? err.message : String(err), output: getErrorMessage(err),
}); });
} }
} }
@@ -164,7 +170,7 @@ export class OrchestratorVerifier {
type: 'ai_review', type: 'ai_review',
description: `AI review of "${phase.name}"`, description: `AI review of "${phase.name}"`,
passed: false, 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 { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { RESEARCH_AGENT_PROMPT, PLANNER_PROMPT } from './prompts/index.js'; 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 // Re-export for backward compatibility
export type { PlanItem }; export type { PlanItem };
@@ -231,6 +231,49 @@ export class PlanOrchestrator {
return md; 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> { async cancel(): Promise<void> {
this.cancelled = true; this.cancelled = true;
// Stop all running sessions and await cleanup to prevent PTY process leaks // Stop all running sessions and await cleanup to prevent PTY process leaks
@@ -312,7 +355,7 @@ export class PlanOrchestrator {
} catch (err) { } catch (err) {
return { return {
success: false, 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']; const parts: string[] = ['## Research Context\n'];
if (research.findings.externalResources.length > 0) { this._formatResearchSection(parts, '### External Resources', research.findings.externalResources, (item) => {
parts.push('### External Resources'); const r = item as ResearchResult['findings']['externalResources'][number];
for (const r of research.findings.externalResources.slice(0, 5)) { const lines = [`- ${r.title}${r.url ? ` (${r.url})` : ''}`];
parts.push(`- ${r.title}${r.url ? ` (${r.url})` : ''}`); if (r.keyInsights.length > 0) {
if (r.keyInsights.length > 0) { lines.push(` Key insights: ${r.keyInsights.slice(0, 3).join(', ')}`);
parts.push(` Key insights: ${r.keyInsights.slice(0, 3).join(', ')}`);
}
} }
parts.push(''); return lines;
} });
if (research.findings.codebasePatterns.length > 0) { this._formatResearchSection(parts, '### Existing Codebase Patterns', research.findings.codebasePatterns, (item) => {
parts.push('### Existing Codebase Patterns'); const p = item as ResearchResult['findings']['codebasePatterns'][number];
for (const p of research.findings.codebasePatterns.slice(0, 5)) { return [`- ${p.pattern} at ${p.location}`];
parts.push(`- ${p.pattern} at ${p.location}`); });
}
parts.push('');
}
if (research.findings.technicalRecommendations.length > 0) { this._formatResearchSection(parts, '### Recommendations', research.findings.technicalRecommendations, (item) => [
parts.push('### Recommendations'); `- ${item as string}`,
for (const r of research.findings.technicalRecommendations.slice(0, 5)) { ]);
parts.push(`- ${r}`);
}
parts.push('');
}
return parts.join('\n'); return parts.join('\n');
} }
@@ -414,18 +448,20 @@ export class PlanOrchestrator {
const durationMs = Date.now() - startTime; const durationMs = Date.now() - startTime;
// Extract JSON from response console.log(
const jsonMatch = response.match(/\{[\s\S]*\}/); `[PlanOrchestrator] Research response length: ${response.length}, first 500 chars:`,
if (!jsonMatch) { response.substring(0, 500)
onSubagent?.({ );
type: 'failed',
agentId, // Extract JSON from response — try multiple strategies
agentType: 'research', const jsonStr = this._extractJsonFromResponse(response);
model: this.researchModel,
status: 'failed', if (!jsonStr) {
error: 'No JSON found', console.error(
durationMs, `[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 { return {
success: false, success: false,
findings: { findings: {
@@ -441,17 +477,9 @@ export class PlanOrchestrator {
}; };
} }
const parsed = tryParseJSON(jsonMatch[0]); const parsed = tryParseJSON(jsonStr);
if (!parsed.success) { if (!parsed.success) {
onSubagent?.({ this._emitAgentFailure(onSubagent, agentId, 'research', this.researchModel, parsed.error!, durationMs);
type: 'failed',
agentId,
agentType: 'research',
model: this.researchModel,
status: 'failed',
error: parsed.error,
durationMs,
});
return { return {
success: false, success: false,
findings: { findings: {
@@ -495,16 +523,8 @@ export class PlanOrchestrator {
return result; return result;
} catch (err) { } catch (err) {
const durationMs = Date.now() - startTime; const durationMs = Date.now() - startTime;
const error = err instanceof Error ? err.message : String(err); const error = getErrorMessage(err);
onSubagent?.({ this._emitAgentFailure(onSubagent, agentId, 'research', this.researchModel, error, durationMs);
type: 'failed',
agentId,
agentType: 'research',
model: this.researchModel,
status: 'failed',
error,
durationMs,
});
return { return {
success: false, success: false,
findings: { findings: {
@@ -587,32 +607,26 @@ export class PlanOrchestrator {
const durationMs = Date.now() - startTime; const durationMs = Date.now() - startTime;
// Extract JSON from response console.log(
const jsonMatch = response.match(/\{[\s\S]*\}/); `[PlanOrchestrator] Planner response length: ${response.length}, first 500 chars:`,
if (!jsonMatch) { response.substring(0, 500)
onSubagent?.({ );
type: 'failed',
agentId, // Extract JSON from response — try multiple strategies
agentType: 'planner', const jsonStr = this._extractJsonFromResponse(response);
model: this.plannerModel,
status: 'failed', if (!jsonStr) {
error: 'No JSON found', console.error(
durationMs, `[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' }; return { success: false, error: 'No JSON in response' };
} }
const parsed = tryParseJSON(jsonMatch[0]); const parsed = tryParseJSON(jsonStr);
if (!parsed.success) { if (!parsed.success) {
onSubagent?.({ this._emitAgentFailure(onSubagent, agentId, 'planner', this.plannerModel, parsed.error!, durationMs);
type: 'failed',
agentId,
agentType: 'planner',
model: this.plannerModel,
status: 'failed',
error: parsed.error,
durationMs,
});
return { success: false, error: parsed.error }; return { success: false, error: parsed.error };
} }
@@ -637,16 +651,8 @@ export class PlanOrchestrator {
return { success: true, items, gaps, warnings }; return { success: true, items, gaps, warnings };
} catch (err) { } catch (err) {
const durationMs = Date.now() - startTime; const durationMs = Date.now() - startTime;
const error = err instanceof Error ? err.message : String(err); const error = getErrorMessage(err);
onSubagent?.({ this._emitAgentFailure(onSubagent, agentId, 'planner', this.plannerModel, error, durationMs);
type: 'failed',
agentId,
agentType: 'planner',
model: this.plannerModel,
status: 'failed',
error,
durationMs,
});
return { success: false, error }; return { success: false, error };
} finally { } finally {
// Always clean up session and progress interval — centralizing here // 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, /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. * RalphStatusParser - Parses RALPH_STATUS blocks and manages circuit breaker.
* *
@@ -303,85 +364,21 @@ export class RalphStatusParser extends EventEmitter {
const trimmedLine = line.trim(); const trimmedLine = line.trim();
if (!trimmedLine) continue; if (!trimmedLine) continue;
// Track whether this line matched any known field
let matched = false; let matched = false;
// STATUS field (required) for (const parser of FIELD_PARSERS) {
const statusMatch = trimmedLine.match(RALPH_STATUS_FIELD_PATTERN); const match = trimmedLine.match(parser.pattern);
if (statusMatch) { if (match) {
const value = statusMatch[1].toUpperCase(); const rawValue = match[1];
if (['IN_PROGRESS', 'COMPLETE', 'BLOCKED'].includes(value)) { if (parser.validate(rawValue)) {
block.status = value as RalphStatusValue; // eslint-disable-next-line @typescript-eslint/no-explicit-any
} else { (block as any)[parser.field] = parser.transform(rawValue);
parseErrors.push(`Invalid STATUS value: "${value}". Expected: IN_PROGRESS, COMPLETE, or BLOCKED`); } 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) // 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; const prevState = this._circuitBreaker.state;
if (hasProgress) { if (hasProgress) {
// Progress detected - reset counters, possibly close circuit this._handleProgressDetected();
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';
}
} else { } else {
// No progress this._handleNoProgress();
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';
}
}
} }
// Track tests failure // 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). * Check line for completion indicators (natural language patterns).
* Used for dual-condition exit gate. * 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; 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). * Debounce interval for event emissions (milliseconds).
* Prevents UI jitter from rapid consecutive updates. * Prevents UI jitter from rapid consecutive updates.
@@ -1299,6 +1311,24 @@ export class RalphTracker extends EventEmitter {
this.detectTodoItems(trimmed); 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. * Detect "all tasks complete" messages.
*/ */
@@ -1318,16 +1348,7 @@ export class RalphTracker extends EventEmitter {
return; return;
} }
let updated = false; this.completeAllTodos();
for (const todo of this._todos.values()) {
if (todo.status !== 'completed') {
todo.status = 'completed';
updated = true;
}
}
if (updated) {
this.emit('todoUpdate', this.todos);
}
if (this._loopState.completionPhrase) { if (this._loopState.completionPhrase) {
this._loopState.active = false; this._loopState.active = false;
@@ -1425,16 +1446,7 @@ export class RalphTracker extends EventEmitter {
if (bareCount > 1) return; if (bareCount > 1) return;
let updated = false; this.completeAllTodos();
for (const todo of this._todos.values()) {
if (todo.status !== 'completed') {
todo.status = 'completed';
updated = true;
}
}
if (updated) {
this.emit('todoUpdate', this.todos);
}
this._loopState.active = false; this._loopState.active = false;
this._loopState.lastActivity = Date.now(); this._loopState.lastActivity = Date.now();
@@ -1480,16 +1492,7 @@ export class RalphTracker extends EventEmitter {
if (canonicalCount >= 2 || this._loopState.active) { if (canonicalCount >= 2 || this._loopState.active) {
this._loopState.active = false; this._loopState.active = false;
this._loopState.lastActivity = Date.now(); this._loopState.lastActivity = Date.now();
let updated = false; this.completeAllTodos();
for (const todo of this._todos.values()) {
if (todo.status !== 'completed') {
todo.status = 'completed';
updated = true;
}
}
if (updated) {
this.emit('todoUpdate', this.todos);
}
this.emit('completionDetected', matchedPhrase); this.emit('completionDetected', matchedPhrase);
this.emit('loopUpdate', this.loopState); this.emit('loopUpdate', this.loopState);
return; return;
@@ -1497,16 +1500,7 @@ export class RalphTracker extends EventEmitter {
} }
if (this._loopState.active || count >= 2) { if (this._loopState.active || count >= 2) {
let updated = false; this.completeAllTodos();
for (const todo of this._todos.values()) {
if (todo.status !== 'completed') {
todo.status = 'completed';
updated = true;
}
}
if (updated) {
this.emit('todoUpdate', this.todos);
}
this._loopState.active = false; this._loopState.active = false;
this._loopState.lastActivity = Date.now(); this._loopState.lastActivity = Date.now();
@@ -1532,41 +1526,39 @@ export class RalphTracker extends EventEmitter {
const suggestedPhrase = `${phrase}_${uniqueSuffix}`; const suggestedPhrase = `${phrase}_${uniqueSuffix}`;
if (COMMON_COMPLETION_PHRASES.has(normalized)) { if (COMMON_COMPLETION_PHRASES.has(normalized)) {
console.warn( this.emitValidationWarning(phrase, 'common', suggestedPhrase);
`[RalphTracker] Warning: Completion phrase "${phrase}" is very common and may cause false positives. Consider using: "${suggestedPhrase}"`
);
this.emit('phraseValidationWarning', {
phrase,
reason: 'common',
suggestedPhrase,
});
return; return;
} }
if (normalized.length < MIN_RECOMMENDED_PHRASE_LENGTH) { if (normalized.length < MIN_RECOMMENDED_PHRASE_LENGTH) {
console.warn( this.emitValidationWarning(phrase, 'short', suggestedPhrase);
`[RalphTracker] Warning: Completion phrase "${phrase}" is too short (${normalized.length} chars). Consider using: "${suggestedPhrase}"`
);
this.emit('phraseValidationWarning', {
phrase,
reason: 'short',
suggestedPhrase,
});
return; return;
} }
if (/^\d+$/.test(normalized)) { if (/^\d+$/.test(normalized)) {
console.warn( this.emitValidationWarning(phrase, 'numeric', suggestedPhrase);
`[RalphTracker] Warning: Completion phrase "${phrase}" is numeric-only and may cause false positives. Consider using: "${suggestedPhrase}"`
);
this.emit('phraseValidationWarning', {
phrase,
reason: '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. * Activate the loop if not already active.
*/ */
@@ -1977,9 +1969,9 @@ export class RalphTracker extends EventEmitter {
let threshold: number; let threshold: number;
if (normalized.length < 30) { if (normalized.length < 30) {
threshold = 0.95; threshold = SIMILARITY_THRESHOLD_SHORT;
} else if (normalized.length < 60) { } else if (normalized.length < 60) {
threshold = 0.9; threshold = SIMILARITY_THRESHOLD_MEDIUM;
} else { } else {
threshold = TODO_SIMILARITY_THRESHOLD; threshold = TODO_SIMILARITY_THRESHOLD;
} }
+264 -190
View File
@@ -70,12 +70,13 @@ import {
AI_PLAN_CHECK_TIMEOUT_MS, AI_PLAN_CHECK_TIMEOUT_MS,
AI_PLAN_CHECK_COOLDOWN_MS, AI_PLAN_CHECK_COOLDOWN_MS,
} from './config/ai-defaults.js'; } from './config/ai-defaults.js';
import type { import {
RespawnCycleMetrics, getErrorMessage,
RespawnAggregateMetrics, type RespawnCycleMetrics,
RalphLoopHealthScore, type RespawnAggregateMetrics,
TimingHistory, type RalphLoopHealthScore,
CycleOutcome, type TimingHistory,
type CycleOutcome,
} from './types.js'; } from './types.js';
// ========== Constants ========== // ========== Constants ==========
@@ -551,6 +552,14 @@ export interface RespawnEvents {
respawnBlocked: (data: { reason: string; details: string }) => void; 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 */ /** Default configuration values */
const DEFAULT_CONFIG: RespawnConfig = { const DEFAULT_CONFIG: RespawnConfig = {
idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback) 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 { private validateConfig(): void {
const c = this.config; const c = this.config;
// Ensure timeouts are positive /**
if (c.idleTimeoutMs <= 0) c.idleTimeoutMs = DEFAULT_CONFIG.idleTimeoutMs; * Validate that a timeout value is positive (or non-negative when allowZero is true).
if (c.completionConfirmMs <= 0) c.completionConfirmMs = DEFAULT_CONFIG.completionConfirmMs; * Falls back to the DEFAULT_CONFIG value if invalid.
if (c.noOutputTimeoutMs <= 0) c.noOutputTimeoutMs = DEFAULT_CONFIG.noOutputTimeoutMs; */
if (c.autoAcceptDelayMs < 0) c.autoAcceptDelayMs = DEFAULT_CONFIG.autoAcceptDelayMs; const validatePositiveTimeout = (field: keyof RespawnConfig, allowZero = false): void => {
if (c.interStepDelayMs <= 0) c.interStepDelayMs = DEFAULT_CONFIG.interStepDelayMs; 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 // Ensure completion confirm doesn't exceed no-output timeout
if (c.completionConfirmMs > c.noOutputTimeoutMs) { if (c.completionConfirmMs > c.noOutputTimeoutMs) {
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) */ /** 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)'; waitingFor = 'AI verdict (IDLE or WORKING)';
} else if (this._state === 'confirming_idle') { } else if (this._state === 'confirming_idle') {
statusText = `Confirming idle (${confidence}% confidence)`; 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') { } else if (this._state === 'watching') {
const aiState = this.aiChecker.getState(); const aiState = this.aiChecker.getState();
if (aiState.status === 'cooldown') { 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)`; statusText = `AI Check: WORKING (cooldown ${remaining}s)`;
waitingFor = 'Cooldown to expire'; waitingFor = 'Cooldown to expire';
} else if (completionMessageDetected) { } else if (completionMessageDetected) {
@@ -1364,98 +1388,13 @@ export class RespawnController extends EventEmitter {
this.lastTokenChangeTime = now; this.lastTokenChangeTime = now;
} }
// Detect completion message FIRST (Layer 1) - PRIMARY DETECTION // Layer 1: Completion message (PRIMARY) — checked before working patterns
// Check this before working patterns because completion message indicates if (this._detectCompletionMessage(data, now)) return;
// 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)}..."`);
// In watching state, start completion confirmation timer // Layer 4: Working patterns
if (this._state === 'watching') { if (this._detectWorkingPattern(data, now)) return;
this.startCompletionConfirmTimer();
return;
}
// In waiting states, also use confirmation timer (same detection logic) // Substantial output during confirming_idle/ai_checking cancels the flow
// 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.
if (this._state === 'confirming_idle' || this._state === 'ai_checking') { if (this._state === 'confirming_idle' || this._state === 'ai_checking') {
// Strip ANSI escape codes to check if there's real content // Strip ANSI escape codes to check if there's real content
ANSI_ESCAPE_PATTERN_SIMPLE.lastIndex = 0; 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) // Legacy fallback: prompt detection
const hasPrompt = PROMPT_PATTERNS.some((pattern) => data.includes(pattern)); this._detectPrompt(data);
if (hasPrompt) { }
this.promptDetected = true;
this.workingDetected = false;
// Handle legacy detection in waiting states - also use confirmation timers private _detectCompletionMessage(data: string, now: number): boolean {
switch (this._state) { if (!isCompletionMessage(data)) return false;
case 'waiting_update':
this.startStepConfirmTimer('update'); // Clear the rolling window - completion marks a transition point
break; this.clearWorkingPatternWindow();
case 'waiting_clear': this.workingDetected = false;
this.checkClearComplete(); // /clear is quick, no need to wait this.completionMessageTime = now;
break; this.cancelAutoAcceptTimer(); // Normal idle flow handles this
case 'waiting_init': this.log(`Completion message detected: "${data.trim().substring(0, 50)}..."`);
this.startStepConfirmTimer('init');
break; // In watching state, start completion confirmation timer
case 'monitoring_init': if (this._state === 'watching') {
this.checkMonitoringInitIdle(); this.startCompletionConfirmTimer();
break; return true;
case 'waiting_kickstart': }
this.startStepConfirmTimer('kickstart');
break; // In waiting states, also use confirmation timer (same detection logic)
// Non-waiting states: prompt detection is informational only // This ensures we wait for Claude to finish before proceeding
case 'watching': // Note: 'watching' is already handled above and returns early
case 'confirming_idle': switch (this._state) {
case 'ai_checking': case 'waiting_update':
case 'sending_update': this.startStepConfirmTimer('update');
case 'sending_clear': break;
case 'sending_init': case 'waiting_clear':
case 'sending_kickstart': this.checkClearComplete(); // /clear is quick, no need to wait
case 'stopped': break;
// Prompt detection during these states doesn't trigger action case 'waiting_init':
break; this.startStepConfirmTimer('init');
default: break;
assertNever(this._state, `Unhandled RespawnState in prompt detection: ${this._state}`); 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_init':
case 'sending_kickstart': case 'sending_kickstart':
// For sending states, retry the send // For sending states, retry the send
this.log('Recovery: returning to watching state'); this.recoveryResetToWatching('returning to watching state');
this.setState('watching');
this.startNoOutputTimer();
this.startPreFilterTimer();
if (this.config.autoAcceptPrompts) {
this.startAutoAcceptTimer();
}
break; break;
default: default:
// Fallback: reset to watching // Fallback: reset to watching
this.log('Recovery: fallback to watching state'); this.recoveryResetToWatching('fallback to watching state');
this.setState('watching'); }
this.startNoOutputTimer(); }
this.startPreFilterTimer();
if (this.config.autoAcceptPrompts) { /**
this.startAutoAcceptTimer(); * 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();
} }
} }
@@ -2041,6 +2078,18 @@ export class RespawnController extends EventEmitter {
return; return;
} }
// Check for active child processes (bash tools, test suites, builds, etc.)
// These may produce no terminal output, so restart timers to retry periodically.
const activeProcesses = this.session.getActiveChildProcesses();
if (activeProcesses.length > 0) {
const names = activeProcesses.map((p) => p.command).join(', ');
this.log(`Skipping AI check - ${activeProcesses.length} active child process(es): ${names}`);
this.logAction('detection', `Skipped AI check: child processes running (${names})`);
this.startNoOutputTimer();
this.startPreFilterTimer();
return;
}
// If AI check is disabled or errored out, fall back to direct idle confirmation // If AI check is disabled or errored out, fall back to direct idle confirmation
if (!this.config.aiIdleCheckEnabled || this.aiChecker.status === 'disabled') { if (!this.config.aiIdleCheckEnabled || this.aiChecker.status === 'disabled') {
this.log(`AI check unavailable (${this.aiChecker.status}), confirming idle directly via: ${reason}`); this.log(`AI check unavailable (${this.aiChecker.status}), confirming idle directly via: ${reason}`);
@@ -2051,7 +2100,7 @@ export class RespawnController extends EventEmitter {
// If on cooldown, don't start check - wait for cooldown to expire // If on cooldown, don't start check - wait for cooldown to expire
if (this.aiChecker.isOnCooldown()) { if (this.aiChecker.isOnCooldown()) {
this.log( 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; return;
} }
@@ -2136,7 +2185,7 @@ export class RespawnController extends EventEmitter {
} }
if (this._state === 'stopped') return; // Guard against stopped state if (this._state === 'stopped') return; // Guard against stopped state
if (this._state === 'ai_checking') { if (this._state === 'ai_checking') {
const errorMsg = err instanceof Error ? err.message : String(err); const errorMsg = getErrorMessage(err);
this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`); this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`);
this.emit('aiCheckFailed', errorMsg); this.emit('aiCheckFailed', errorMsg);
this.setState('watching'); this.setState('watching');
@@ -2198,36 +2247,15 @@ export class RespawnController extends EventEmitter {
* @fires planCheckStarted * @fires planCheckStarted
*/ */
private tryAutoAccept(): void { private tryAutoAccept(): void {
// Only auto-accept in watching state (not during a respawn cycle) if (!this.canAutoAccept()) return;
if (this._state !== 'watching') 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; 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) // Stage 2: AI confirmation (if enabled and available)
if (this.config.aiPlanCheckEnabled && this.planChecker.status !== 'disabled') { if (this.config.aiPlanCheckEnabled && this.planChecker.status !== 'disabled') {
if (this.planChecker.isOnCooldown()) { if (this.planChecker.isOnCooldown()) {
this.log( 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; return;
} }
@@ -2244,6 +2272,40 @@ export class RespawnController extends EventEmitter {
this.sendAutoAcceptEnter(); 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. * Check if the terminal buffer matches plan mode pre-filter patterns.
* Only checks the last 2000 chars (plan mode UI appears at the bottom). * Only checks the last 2000 chars (plan mode UI appears at the bottom).
@@ -2322,7 +2384,7 @@ export class RespawnController extends EventEmitter {
} }
}) })
.catch((err) => { .catch((err) => {
const errorMsg = err instanceof Error ? err.message : String(err); const errorMsg = getErrorMessage(err);
this.emit('planCheckFailed', errorMsg); this.emit('planCheckFailed', errorMsg);
this.logAction('plan-check', `Failed: ${errorMsg.substring(0, 50)}`); this.logAction('plan-check', `Failed: ${errorMsg.substring(0, 50)}`);
}); });
@@ -2632,6 +2694,18 @@ export class RespawnController extends EventEmitter {
return; return;
} }
// Safety check: if child processes are running (bash tools, test suites, builds, etc.)
const activeProcesses = this.session.getActiveChildProcesses();
if (activeProcesses.length > 0) {
const names = activeProcesses.map((p) => p.command).join(', ');
this.log(`Idle confirmation rejected - ${activeProcesses.length} active child process(es): ${names}`);
this.logAction('detection', `Rejected: child processes running (${names})`);
this.setState('watching');
this.startNoOutputTimer();
this.startPreFilterTimer();
return;
}
this.log(`Idle confirmed via: ${reason}`); this.log(`Idle confirmed via: ${reason}`);
const status = this.getDetectionStatus(); const status = this.getDetectionStatus();
this.log( this.log(
+98 -51
View File
@@ -27,6 +27,57 @@ const COMPACT_COOLDOWN_MS = 10000;
/** Cooldown after clear completes before re-enabling (5 seconds) */ /** Cooldown after clear completes before re-enabling (5 seconds) */
const CLEAR_COOLDOWN_MS = 5000; 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) */ /** Minimum valid threshold for auto-clear/compact (1000 tokens) */
const MIN_AUTO_THRESHOLD = 1000; const MIN_AUTO_THRESHOLD = 1000;
@@ -181,37 +232,35 @@ export class SessionAutoOps extends EventEmitter {
`[SessionAutoOps] Auto-compact triggered: ${totalTokens} tokens >= ${this._autoCompactThreshold} threshold` `[SessionAutoOps] Auto-compact triggered: ${totalTokens} tokens >= ${this._autoCompactThreshold} threshold`
); );
const checkAndCompact = async () => { const action = async () => {
if (this.callbacks.isStopped()) return; const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r';
if (!this._isCompacting) return; await this.callbacks.writeCommand(compactCmd);
this.emit('autoCompact', {
if (!this.callbacks.isWorking()) { tokens: totalTokens,
if (this.callbacks.isStopped()) return; threshold: this._autoCompactThreshold,
prompt: this._autoCompactPrompt || undefined,
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);
}
}
}; };
if (!this.callbacks.isStopped()) { 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` `[SessionAutoOps] Auto-clear triggered: ${totalTokens} tokens >= ${this._autoClearThreshold} threshold`
); );
const checkAndClear = async () => { const action = async () => {
if (this.callbacks.isStopped()) return; await this.callbacks.writeCommand('/clear\r');
if (!this._isClearing) return; this.emit('autoClear', { tokens: totalTokens, threshold: this._autoClearThreshold });
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);
}
}
}; };
if (!this.callbacks.isStopped()) { 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
);
} }
} }
} }
+253 -243
View File
@@ -29,6 +29,7 @@
*/ */
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { execSync } from 'node:child_process';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import * as pty from 'node-pty'; import * as pty from 'node-pty';
import { import {
@@ -40,6 +41,7 @@ import {
ActiveBashTool, ActiveBashTool,
NiceConfig, NiceConfig,
DEFAULT_NICE_CONFIG, DEFAULT_NICE_CONFIG,
getErrorMessage,
type ClaudeMode, type ClaudeMode,
type SessionMode, type SessionMode,
type OpenCodeConfig, type OpenCodeConfig,
@@ -64,6 +66,7 @@ import {
MAX_MESSAGES, MAX_MESSAGES,
MAX_LINE_BUFFER_SIZE, MAX_LINE_BUFFER_SIZE,
} from './config/buffer-limits.js'; } from './config/buffer-limits.js';
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
import { import {
buildInteractiveArgs, buildInteractiveArgs,
buildPromptArgs, buildPromptArgs,
@@ -538,6 +541,49 @@ export class Session extends EventEmitter {
return this._isWorking; return this._isWorking;
} }
/**
* Check if the session's process tree has active child processes beyond Claude itself.
* Detects running bash tools, test suites, builds, servers, etc. that Claude spawned.
*
* The tmux pane PID is typically "claude" directly (bash exec'd into it). When Claude
* runs a bash tool, it spawns child processes: claude → bash → npm/node/python/etc.
* We check direct children of the pane PID, filtering out "claude" itself (for the rare
* case where bash wraps claude and didn't exec).
*
* Returns an array of {pid, command} for each child process, or empty array if none.
* Returns empty array if no mux session or on error (fail-open to avoid blocking respawn).
*/
getActiveChildProcesses(): { pid: number; command: string }[] {
if (!this._muxSession) return [];
try {
const panePid = this._muxSession.pid;
// Single call: get direct children with their command names
const output = execSync(`ps -o pid=,comm= --ppid ${panePid} 2>/dev/null`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
if (!output) return [];
const activeProcesses: { pid: number; command: string }[] = [];
for (const line of output.split('\n')) {
const match = line.trim().match(/^(\d+)\s+(.+)/);
if (!match) continue;
const pid = parseInt(match[1], 10);
const command = match[2].trim();
// Skip the claude process itself (pane_pid may be bash wrapping claude)
if (command === 'claude') continue;
activeProcesses.push({ pid, command });
}
return activeProcesses;
} catch {
// ps returns exit code 1 when no matches — normal (no children)
return [];
}
}
get lastPromptTime(): number { get lastPromptTime(): number {
return this._lastPromptTime; return this._lastPromptTime;
} }
@@ -871,18 +917,77 @@ export class Session extends EventEmitter {
* session.write('help me with this code\r'); * 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> { async startInteractive(): Promise<void> {
if (this.ptyProcess) { if (this.ptyProcess) {
throw new Error('Session already has a running process'); throw new Error('Session already has a running process');
} }
this._status = 'busy'; this._resetBuffers();
this._terminalBuffer.clear();
this._textOutput.clear();
this._errorBuffer = '';
this._messages = [];
this._lineBuffer = '';
this._lastActivityAt = Date.now();
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : 'Claude'; const modeLabel = this.mode === 'opencode' ? 'OpenCode' : 'Claude';
console.log( console.log(
@@ -892,18 +997,8 @@ export class Session extends EventEmitter {
// If mux wrapping is enabled, create or attach to a mux session // If mux wrapping is enabled, create or attach to a mux session
if (this._useMux && this._mux) { if (this._useMux && this._mux) {
try { try {
// Verify stale mux session — tmux may have been destroyed (e.g., killed externally) const { isRestored } = await this._setupOrAttachMuxSession({
if (this._muxSession && !this._mux.muxSessionExists(this._muxSession.muxName)) { respawnPaneOptions: {
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({
sessionId: this.id, sessionId: this.id,
workingDir: this.workingDir, workingDir: this.workingDir,
mode: this.mode, mode: this.mode,
@@ -913,23 +1008,8 @@ export class Session extends EventEmitter {
allowedTools: this._allowedTools, allowedTools: this._allowedTools,
openCodeConfig: this._openCodeConfig, openCodeConfig: this._openCodeConfig,
resumeSessionId: this._resumeSessionId, resumeSessionId: this._resumeSessionId,
}); },
if (!newPid) { createSessionOptions: {
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({
sessionId: this.id, sessionId: this.id,
workingDir: this.workingDir, workingDir: this.workingDir,
mode: this.mode, mode: this.mode,
@@ -940,36 +1020,16 @@ export class Session extends EventEmitter {
allowedTools: this._allowedTools, allowedTools: this._allowedTools,
openCodeConfig: this._openCodeConfig, openCodeConfig: this._openCodeConfig,
resumeSessionId: this._resumeSessionId, resumeSessionId: this._resumeSessionId,
}); },
console.log('[Session] Created mux session:', this._muxSession.muxName); spawnErrLabel: 'mux attachment',
// No extra sleep — createSession() already waits for tmux readiness });
}
// Attach to the mux session via PTY // Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
try { this._claudeSessionId = this._resumeSessionId || this.id;
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;
}
// For NEW mux sessions: wait for readiness then clean buffer // 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 // For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch
if (!isRestoredSession) { if (!isRestored) {
if (this.mode === 'opencode') { if (this.mode === 'opencode') {
// OpenCode uses Bubble Tea TUI — no ❯ prompt to detect. // OpenCode uses Bubble Tea TUI — no ❯ prompt to detect.
// Wait for TUI to stabilize (output stops changing), then mark ready. // Wait for TUI to stabilize (output stops changing), then mark ready.
@@ -1056,12 +1116,7 @@ export class Session extends EventEmitter {
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '').replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L const data = rawData.replace(FOCUS_ESCAPE_FILTER, '').replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L
if (!data) return; // Skip if only filtered sequences if (!data) return; // Skip if only filtered sequences
// BufferAccumulator handles auto-trimming when max size exceeded this._handleTerminalOutput(data);
this._terminalBuffer.append(data);
this._lastActivityAt = Date.now();
this.emit('terminal', data);
this.emit('output', data);
// === Idle/working detection runs on every chunk (latency-sensitive) === // === Idle/working detection runs on every chunk (latency-sensitive) ===
// Detect if Claude is working or at prompt // Detect if Claude is working or at prompt
@@ -1257,13 +1312,7 @@ export class Session extends EventEmitter {
throw new Error('Session already has a running process'); throw new Error('Session already has a running process');
} }
this._status = 'busy'; this._resetBuffers();
this._terminalBuffer.clear();
this._textOutput.clear();
this._errorBuffer = '';
this._messages = [];
this._lineBuffer = '';
this._lastActivityAt = Date.now();
// Use user's default shell or bash // Use user's default shell or bash
const shell = process.env.SHELL || '/bin/bash'; const shell = process.env.SHELL || '/bin/bash';
@@ -1275,69 +1324,26 @@ export class Session extends EventEmitter {
// If mux wrapping is enabled, create or attach to a mux session // If mux wrapping is enabled, create or attach to a mux session
if (this._useMux && this._mux) { if (this._useMux && this._mux) {
try { try {
// Verify stale mux session — tmux may have been destroyed externally const { isRestored } = await this._setupOrAttachMuxSession({
if (this._muxSession && !this._mux.muxSessionExists(this._muxSession.muxName)) { respawnPaneOptions: {
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({
sessionId: this.id, sessionId: this.id,
workingDir: this.workingDir, workingDir: this.workingDir,
mode: 'shell', mode: 'shell',
niceConfig: this._niceConfig, niceConfig: this._niceConfig,
}); },
if (!newPid) { createSessionOptions: {
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({
sessionId: this.id, sessionId: this.id,
workingDir: this.workingDir, workingDir: this.workingDir,
mode: 'shell', mode: 'shell',
name: this._name, name: this._name,
niceConfig: this._niceConfig, niceConfig: this._niceConfig,
}); },
console.log('[Session] Created mux session:', this._muxSession.muxName); spawnErrLabel: 'shell mux attachment',
// 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;
}
// For NEW sessions: clear by sending 'clear' command to the shell // For NEW sessions: clear by sending 'clear' command to the shell
// For RESTORED sessions: don't clear - we want to see the existing output // For RESTORED sessions: don't clear - we want to see the existing output
if (!isRestoredSession) { if (!isRestored) {
setTimeout(() => { setTimeout(() => {
if (this.ptyProcess) { if (this.ptyProcess) {
this._terminalBuffer.clear(); this._terminalBuffer.clear();
@@ -1378,12 +1384,7 @@ export class Session extends EventEmitter {
const data = rawData.replace(FOCUS_ESCAPE_FILTER, ''); const data = rawData.replace(FOCUS_ESCAPE_FILTER, '');
if (!data) return; // Skip if only focus sequences if (!data) return; // Skip if only focus sequences
// BufferAccumulator handles auto-trimming when max size exceeded this._handleTerminalOutput(data);
this._terminalBuffer.append(data);
this._lastActivityAt = Date.now();
this.emit('terminal', data);
this.emit('output', data);
}); });
this.ptyProcess.onExit(({ exitCode }) => { this.ptyProcess.onExit(({ exitCode }) => {
@@ -1448,13 +1449,7 @@ export class Session extends EventEmitter {
return; return;
} }
this._status = 'busy'; this._resetBuffers();
this._terminalBuffer.clear();
this._textOutput.clear();
this._errorBuffer = '';
this._messages = [];
this._lineBuffer = '';
this._lastActivityAt = Date.now();
this._promptResolved = false; // Reset race condition guard this._promptResolved = false; // Reset race condition guard
this.resolvePromise = resolve; this.resolvePromise = resolve;
@@ -1497,12 +1492,7 @@ export class Session extends EventEmitter {
const data = rawData.replace(FOCUS_ESCAPE_FILTER, ''); const data = rawData.replace(FOCUS_ESCAPE_FILTER, '');
if (!data) return; // Skip if only focus sequences if (!data) return; // Skip if only focus sequences
// BufferAccumulator handles auto-trimming when max size exceeded this._handleTerminalOutput(data);
this._terminalBuffer.append(data);
this._lastActivityAt = Date.now();
this.emit('terminal', data);
this.emit('output', data);
// Also try to parse JSON lines for structured data // Also try to parse JSON lines for structured data
this.processOutput(data); this.processOutput(data);
@@ -1534,9 +1524,11 @@ export class Session extends EventEmitter {
this._status = 'idle'; this._status = 'idle';
const cost = resultMsg.total_cost_usd || 0; const cost = resultMsg.total_cost_usd || 0;
this._totalCost += cost; 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) { if (resolve) {
resolve({ result: resultMsg.result || '', cost }); resolve({ result, cost });
} }
} else if (exitCode !== 0 || (resultMsg && resultMsg.is_error)) { } else if (exitCode !== 0 || (resultMsg && resultMsg.is_error)) {
this._status = 'error'; this._status = 'error';
@@ -1565,6 +1557,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 { private processOutput(data: string): void {
// Early return if session is stopped to prevent any processing or timer creation // Early return if session is stopped to prevent any processing or timer creation
if (this._isStopped) return; if (this._isStopped) return;
@@ -1606,64 +1709,7 @@ export class Session extends EventEmitter {
const cleanLine = trimmed.replace(ANSI_ESCAPE_PATTERN_FULL, ''); const cleanLine = trimmed.replace(ANSI_ESCAPE_PATTERN_FULL, '');
if (cleanLine.startsWith('{') && cleanLine.endsWith('}')) { if (cleanLine.startsWith('{') && cleanLine.endsWith('}')) {
try { this._handleJsonMessage(cleanLine, line);
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');
}
} else if (trimmed) { } else if (trimmed) {
this._textOutput.append(line + '\n'); this._textOutput.append(line + '\n');
} }
@@ -1955,7 +2001,7 @@ export class Session extends EventEmitter {
this._status = 'busy'; this._status = 'busy';
this._lastActivityAt = Date.now(); this._lastActivityAt = Date.now();
this.runPrompt(input).catch((err) => { 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 // Clean up task state so the task queue doesn't get stuck
if (this._currentTaskId) { if (this._currentTaskId) {
const taskId = this._currentTaskId; const taskId = this._currentTaskId;
@@ -2030,43 +2076,7 @@ export class Session extends EventEmitter {
// Set stopped flag first to prevent new timers from being created // Set stopped flag first to prevent new timers from being created
this._isStopped = true; this._isStopped = true;
// Clear activity timeout to prevent memory leak this._clearAllTimers();
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 = '';
// Immediately cleanup Promise callbacks to prevent orphaned references // Immediately cleanup Promise callbacks to prevent orphaned references
// during the rest of stop() processing (e.g., if mux kill times out) // 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(); 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 { private ensureDir(): void {
const dir = dirname(this.filePath); const dir = dirname(this.filePath);
if (!existsSync(dir)) { if (!existsSync(dir)) {
@@ -132,15 +152,7 @@ export class StateStore {
if (existsSync(path)) { if (existsSync(path)) {
const data = readFileSync(path, 'utf-8'); const data = readFileSync(path, 'utf-8');
const parsed = JSON.parse(data) as Partial<AppState>; const parsed = JSON.parse(data) as Partial<AppState>;
const initial = createInitialState(); const result = this._mergeWithInitialState(parsed);
const result = {
...initial,
...parsed,
sessions: { ...parsed.sessions },
tasks: { ...parsed.tasks },
ralphLoop: { ...initial.ralphLoop, ...parsed.ralphLoop },
config: { ...initial.config, ...parsed.config },
};
if (path !== this.filePath) { if (path !== this.filePath) {
console.warn(`[StateStore] Recovered state from backup: ${path}`); 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. * Only dirty sessions are re-serialized; clean sessions use cached JSON fragments.
*/ */
private assembleStateJson(): string { private assembleStateJson(): string {
// Re-serialize dirty sessions and update cache this.updateDirtySessionCache();
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();
// Build sessions object from cached fragments // Build sessions object from cached fragments
const sessionParts: string[] = []; const sessionParts: string[] = [];
@@ -218,6 +221,25 @@ export class StateStore {
sessionParts.push(`${JSON.stringify(id)}:${json}`); 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) // Prune stale cache entries (sessions removed via direct state mutation)
if (this.cachedSessionJsons.size > Object.keys(this.state.sessions).length) { if (this.cachedSessionJsons.size > Object.keys(this.state.sessions).length) {
for (const cachedId of this.cachedSessionJsons.keys()) { 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) // Build final JSON: sessions from cache, everything else re-serialized (tiny)
const sessionsJson = `{${sessionParts.join(',')}}`; const sessionsJson = `{${sessionParts.join(',')}}`;
@@ -249,6 +273,28 @@ export class StateStore {
return `{${parts.join(',')}}`; 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> { private async _doSaveAsync(): Promise<void> {
this.saveDeb.cancel(); this.saveDeb.cancel();
if (!this.dirty) { if (!this.dirty) {
@@ -265,28 +311,10 @@ export class StateStore {
const tempPath = this.filePath + '.tmp'; const tempPath = this.filePath + '.tmp';
const backupPath = this.filePath + '.bak'; const backupPath = this.filePath + '.bak';
let json: string;
// Step 1: Serialize state (validates it's JSON-safe) // Step 1: Serialize state (validates it's JSON-safe)
try { const json = this.serializeState();
json = this.assembleStateJson(); if (json === null) return;
} 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;
}
}
// Clear dirty flag BEFORE async I/O so mutations during write re-set it. // Clear dirty flag BEFORE async I/O so mutations during write re-set it.
// The state snapshot is already captured in `json` above. // The state snapshot is already captured in `json` above.
@@ -305,11 +333,7 @@ export class StateStore {
await writeFile(tempPath, json, 'utf-8'); await writeFile(tempPath, json, 'utf-8');
await rename(tempPath, this.filePath); await rename(tempPath, this.filePath);
this.consecutiveSaveFailures = 0; this._resetCircuitBreaker();
if (this.circuitBreakerOpen) {
console.log('[StateStore] Circuit breaker CLOSED - save succeeded');
this.circuitBreakerOpen = false;
}
} catch (err) { } catch (err) {
console.error('[StateStore] Failed to write state file:', err); console.error('[StateStore] Failed to write state file:', err);
// Re-mark dirty so the data is retried on the next save cycle // 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 tempPath = this.filePath + '.tmp';
const backupPath = this.filePath + '.bak'; const backupPath = this.filePath + '.bak';
let json: string;
try { const json = this.serializeState();
json = this.assembleStateJson(); if (json === null) return;
} 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;
}
}
// Backup via atomic copy (avoids reading entire file into memory) // Backup via atomic copy (avoids reading entire file into memory)
try { try {
@@ -387,11 +393,7 @@ export class StateStore {
renameSync(tempPath, this.filePath); renameSync(tempPath, this.filePath);
// Clear dirty flag only AFTER successful write // Clear dirty flag only AFTER successful write
this.dirty = false; this.dirty = false;
this.consecutiveSaveFailures = 0; this._resetCircuitBreaker();
if (this.circuitBreakerOpen) {
console.log('[StateStore] Circuit breaker CLOSED - save succeeded');
this.circuitBreakerOpen = false;
}
} catch (err) { } catch (err) {
console.error('[StateStore] Failed to write state file:', err); console.error('[StateStore] Failed to write state file:', err);
this.consecutiveSaveFailures++; this.consecutiveSaveFailures++;
@@ -417,15 +419,7 @@ export class StateStore {
if (existsSync(backupPath)) { if (existsSync(backupPath)) {
const backupContent = readFileSync(backupPath, 'utf-8'); const backupContent = readFileSync(backupPath, 'utf-8');
const parsed = JSON.parse(backupContent) as Partial<AppState>; const parsed = JSON.parse(backupContent) as Partial<AppState>;
const initial = createInitialState(); this.state = this._mergeWithInitialState(parsed);
this.state = {
...initial,
...parsed,
sessions: { ...parsed.sessions },
tasks: { ...parsed.tasks },
ralphLoop: { ...initial.ralphLoop, ...parsed.ralphLoop },
config: { ...initial.config, ...parsed.config },
};
console.log('[StateStore] Successfully recovered state from backup'); console.log('[StateStore] Successfully recovered state from backup');
// Reset circuit breaker after successful recovery // Reset circuit breaker after successful recovery
this.circuitBreakerOpen = false; 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)); 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 * Extract short model identifier from full model name
*/ */
@@ -307,10 +384,7 @@ export class SubagentWatcher extends EventEmitter {
const alive = this.checkSubagentAliveFromPidMap(info, pidMap); const alive = this.checkSubagentAliveFromPidMap(info, pidMap);
if (!alive) { if (!alive) {
info.pid = undefined; this.markSubagentAsCompleted(info);
info.status = 'completed';
this.pendingToolCalls.delete(info.agentId);
this.emit('subagent:completed', info);
} }
} }
} }
@@ -677,10 +751,7 @@ export class SubagentWatcher extends EventEmitter {
const pid = await this.findSubagentProcess(info.sessionId); const pid = await this.findSubagentProcess(info.sessionId);
if (pid) { if (pid) {
process.kill(pid, 'SIGTERM'); process.kill(pid, 'SIGTERM');
info.pid = undefined; this.markSubagentAsCompleted(info);
info.status = 'completed';
this.pendingToolCalls.delete(info.agentId);
this.emit('subagent:completed', info);
return true; return true;
} }
} catch { } catch {
@@ -688,10 +759,7 @@ export class SubagentWatcher extends EventEmitter {
} }
// Mark as completed even if we couldn't find the process // Mark as completed even if we couldn't find the process
info.pid = undefined; this.markSubagentAsCompleted(info);
info.status = 'completed';
this.pendingToolCalls.delete(info.agentId);
this.emit('subagent:completed', info);
return true; return true;
} }
@@ -843,19 +911,9 @@ export class SubagentWatcher extends EventEmitter {
} }
} else if (entry.type === 'user' && entry.message?.content) { } else if (entry.type === 'user' && entry.message?.content) {
// Handle both string and array content formats // Handle both string and array content formats
if (typeof entry.message.content === 'string') { const text = this.extractFirstTextContent(entry.message.content);
const text = entry.message.content.trim(); if (text && text.length < 100 && !text.includes('{')) {
if (text.length < 100 && !text.includes('{')) { lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, USER_TEXT_PREVIEW_LENGTH)}`);
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)}`);
}
}
} }
} }
} }
@@ -930,6 +988,24 @@ export class SubagentWatcher extends EventEmitter {
return truncated.replace(/[.!?,:\s]+$/, ''); 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. * Extract the short description from the parent session's transcript.
* This is the most reliable method because it reads the actual Task tool result * This is the most reliable method because it reads the actual Task tool result
@@ -1027,15 +1103,7 @@ export class SubagentWatcher extends EventEmitter {
try { try {
const entry = JSON.parse(line); const entry = JSON.parse(line);
if (entry.type === 'user' && entry.message?.content) { if (entry.type === 'user' && entry.message?.content) {
let text: string | undefined; const text = this.extractFirstTextContent(entry.message.content);
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) { if (text) {
resolved = true; resolved = true;
rl.close(); rl.close();
@@ -1195,18 +1263,13 @@ export class SubagentWatcher extends EventEmitter {
// Retry description extraction if missing (race condition fix) // Retry description extraction if missing (race condition fix)
if (!existingInfo.description) { if (!existingInfo.description) {
// First try parent transcript (most reliable) const extractedDescription = await this._resolveDescription(
let extractedDescription = await this.extractDescriptionFromParentTranscript(
existingInfo.projectHash, existingInfo.projectHash,
existingInfo.sessionId, existingInfo.sessionId,
agentId agentId,
filePath
); );
// Fallback to subagent file
if (!extractedDescription) {
extractedDescription = await this.extractDescriptionFromFile(filePath);
}
if (extractedDescription) { if (extractedDescription) {
// Check if this is an internal agent - if so, remove it
if (this.isInternalAgent(extractedDescription)) { if (this.isInternalAgent(extractedDescription)) {
this.removeAgent(agentId); this.removeAgent(agentId);
return; return;
@@ -1257,13 +1320,7 @@ export class SubagentWatcher extends EventEmitter {
} }
// Extract description - prefer reading from parent transcript (most reliable) // Extract description - prefer reading from parent transcript (most reliable)
// The parent transcript has the exact Task tool call with description parameter const description = await this._resolveDescription(projectHash, sessionId, agentId, filePath);
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);
}
// Skip internal Claude Code agents (e.g., suggestion mode) - not real subagents // Skip internal Claude Code agents (e.g., suggestion mode) - not real subagents
if (this.isInternalAgent(description)) { if (this.isInternalAgent(description)) {
@@ -1286,14 +1343,7 @@ export class SubagentWatcher extends EventEmitter {
// Enforce MAX_TRACKED_AGENTS during insertion — evict oldest inactive agent // Enforce MAX_TRACKED_AGENTS during insertion — evict oldest inactive agent
if (this.agentInfo.size >= MAX_TRACKED_AGENTS) { if (this.agentInfo.size >= MAX_TRACKED_AGENTS) {
let oldestId: string | null = null; const oldestId = this.findOldestInactiveAgent();
let oldestTime = Infinity;
for (const [id, existing] of this.agentInfo) {
if (existing.status !== 'active' && existing.lastActivityAt < oldestTime) {
oldestTime = existing.lastActivityAt;
oldestId = id;
}
}
if (oldestId) { if (oldestId) {
this.removeAgent(oldestId); this.removeAgent(oldestId);
} }
@@ -1363,51 +1413,11 @@ export class SubagentWatcher extends EventEmitter {
private async processEntry(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): Promise<void> { private async processEntry(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): Promise<void> {
const info = this.agentInfo.get(agentId); const info = this.agentInfo.get(agentId);
// Extract model from assistant messages (first one sets the model) if (info) {
if (info && entry.type === 'assistant' && entry.message?.model && !info.model) { this._processModelInfo(entry, info);
info.model = entry.message.model; this._processTokenInfo(entry, info);
info.modelShort = this.extractModelShort(entry.message.model);
this.emit('subagent:updated', info);
}
// Aggregate token usage from messages if (await this._processDescription(entry, agentId, info)) return;
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 (entry.type === 'progress' && entry.data) { if (entry.type === 'progress' && entry.data) {
@@ -1418,7 +1428,6 @@ export class SubagentWatcher extends EventEmitter {
progressType: entry.data.type, progressType: entry.data.type,
query: entry.data.query, query: entry.data.query,
resultCount: entry.data.resultCount, resultCount: entry.data.resultCount,
// Extract hook event info if present
hookEvent: entry.data.hookEvent, hookEvent: entry.data.hookEvent,
hookName: hookName:
entry.data.hookName || entry.data.hookName ||
@@ -1428,139 +1437,166 @@ export class SubagentWatcher extends EventEmitter {
}; };
this.emit('subagent:progress', progress); this.emit('subagent:progress', progress);
} else if (entry.type === 'assistant' && entry.message?.content) { } else if (entry.type === 'assistant' && entry.message?.content) {
// Handle both string and array content formats this._processAssistantContent(entry, agentId, sessionId);
if (typeof entry.message.content === 'string') { } else if (entry.type === 'user' && entry.message?.content) {
const text = entry.message.content.trim(); this._processUserContent(entry, agentId, sessionId);
if (text.length > 0) { }
const message: SubagentMessage = { }
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, agentId,
sessionId, sessionId,
timestamp: entry.timestamp, timestamp: entry.timestamp,
role: 'assistant', tool: content.name,
text: text.substring(0, MESSAGE_TEXT_LIMIT), input: this.getTruncatedInput(content.name, content.input || {}),
toolUseId: content.id,
fullInput: content.input || {},
}; };
this.emit('subagent:message', message); this.emit('subagent:tool_call', toolCall);
}
} 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(),
});
}
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, agentId,
sessionId, sessionId,
timestamp: entry.timestamp, timestamp: entry.timestamp,
tool: content.name, role: 'assistant',
input: this.getTruncatedInput(content.name, content.input || {}), text: text.substring(0, MESSAGE_TEXT_LIMIT),
toolUseId: content.id,
fullInput: content.input || {},
}; };
this.emit('subagent:tool_call', toolCall); this.emit('subagent:message', message);
// 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);
}
} }
} }
} }
} 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(); private _processUserContent(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): void {
if (userText.length > 0 && userText.length < 500) { const messageContent = entry.message!.content;
const message: SubagentMessage = { 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, agentId,
sessionId, sessionId,
timestamp: entry.timestamp, entry.timestamp
role: 'user', );
text: userText, } else if (content.type === 'text' && content.text) {
}; const userText = content.text.trim();
this.emit('subagent:message', message); if (userText.length > 0 && userText.length < 500) {
} const message: SubagentMessage = {
} 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 = {
agentId, agentId,
sessionId, sessionId,
timestamp: entry.timestamp, timestamp: entry.timestamp,
toolUseId: content.tool_use_id, role: 'user',
tool: toolName, text: userText,
preview: resultContent.substring(0, MESSAGE_TEXT_LIMIT),
contentLength: resultContent.length,
isError: content.is_error || false,
}; };
this.emit('subagent:tool_result', toolResult); this.emit('subagent:message', message);
} 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);
}
} }
} }
} }
+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") */ /** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */
const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/; 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. * Validates that a session name contains only safe characters.
* Prevents command injection via malformed session IDs. * Prevents command injection via malformed session IDs.
@@ -111,23 +114,7 @@ function isValidMuxName(name: string): boolean {
* Prevents command injection via malformed paths. * Prevents command injection via malformed paths.
*/ */
function isValidPath(path: string): boolean { function isValidPath(path: string): boolean {
if ( if (UNSAFE_PATH_CHARS.test(path)) {
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')
) {
return false; return false;
} }
if (path.includes('..')) { 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. * Creates a new tmux session wrapping Claude CLI or a shell.
* In test mode: creates an in-memory session only (no real tmux session). * 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 // Resolve CLI binary directory based on mode
let pathExport = ''; const { pathExport, dir: cliDir } = this.buildPathExport(mode);
if (mode === 'claude') { if (mode === 'claude' && !cliDir) {
const claudeDir = findClaudeDir(); throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash');
if (!claudeDir) { }
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');
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 envExports = [ const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
'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 baseCmd = buildSpawnCommand({ const baseCmd = buildSpawnCommand({
mode, mode,
@@ -489,8 +504,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// For OpenCode: set sensitive env vars and config via tmux setenv // For OpenCode: set sensitive env vars and config via tmux setenv
// (not visible in ps output or tmux history, inherited by panes) // (not visible in ps output or tmux history, inherited by panes)
if (mode === 'opencode') { if (mode === 'opencode') {
setOpenCodeEnvVars(muxName); this._configureOpenCode(muxName, openCodeConfig);
setOpenCodeConfigContent(muxName, openCodeConfig);
} }
// Replace the shell with the actual command (no echo in terminal) // 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; if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null;
// Resolve CLI binary directory based on mode // Resolve CLI binary directory based on mode
let pathExport = ''; const { pathExport } = this.buildPathExport(mode);
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 envExports = [ const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
'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 baseCmd = buildSpawnCommand({ const baseCmd = buildSpawnCommand({
mode, mode,
@@ -678,8 +675,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
try { try {
// For OpenCode: set sensitive env vars via tmux setenv before respawn // For OpenCode: set sensitive env vars via tmux setenv before respawn
if (mode === 'opencode') { if (mode === 'opencode') {
setOpenCodeEnvVars(muxName); this._configureOpenCode(muxName, openCodeConfig);
setOpenCodeConfigContent(muxName, openCodeConfig);
} }
await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, { 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, RESTART_DELAY_MS,
FORCE_KILL_MS, FORCE_KILL_MS,
} from './config/tunnel-config.js'; } from './config/tunnel-config.js';
import { getErrorMessage } from './types.js';
// ========== Types ========== // ========== Types ==========
@@ -164,7 +165,7 @@ export class TunnelManager extends EventEmitter {
detached: false, detached: false,
}); });
} catch (err) { } 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; return;
} }
+138 -173
View File
@@ -432,6 +432,25 @@ class CodemanApp {
return this._elemCache[id]; 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 // Format token count: 1000k -> 1m, 1450k -> 1.45m, 500 -> 500
formatTokens(count) { formatTokens(count) {
if (count >= 1000000) { if (count >= 1000000) {
@@ -588,73 +607,43 @@ class CodemanApp {
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
setupEventListeners() { 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 // Use capture to handle before terminal
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
// Don't intercept keys during CJK IME composition // Don't intercept keys during CJK IME composition
if (e.isComposing || e.keyCode === 229) return; 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') { if (e.key === 'Escape') {
this.closeAllPanels(); this.closeAllPanels();
this.closeHelp(); this.closeHelp();
} }
// Ctrl/Cmd + ? - help // Match against shortcut table
if ((e.ctrlKey || e.metaKey) && (e.key === '?' || e.key === '/')) { for (const s of SHORTCUTS) {
e.preventDefault(); const keyMatch = e.key === s.key || (s.altKey && e.key === s.altKey);
this.showHelp(); const ctrlMatch = s.ctrl ? (e.ctrlKey || e.metaKey) : true;
} const shiftMatch = s.shift ? e.shiftKey : !e.shiftKey;
if (keyMatch && ctrlMatch && shiftMatch) {
// Ctrl/Cmd + Enter - quick start e.preventDefault();
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { s.action();
e.preventDefault(); return;
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();
} }
}, true); // Use capture phase to handle before terminal }, true); // Use capture phase to handle before terminal
@@ -682,10 +671,7 @@ class CodemanApp {
} }
// Clear any pending reconnect timeout to prevent duplicate connections // Clear any pending reconnect timeout to prevent duplicate connections
if (this.sseReconnectTimeout) { this._clearTimer('sseReconnectTimeout');
clearTimeout(this.sseReconnectTimeout);
this.sseReconnectTimeout = null;
}
// Clean up existing SSE listeners before creating new connection (prevents listener accumulation) // Clean up existing SSE listeners before creating new connection (prevents listener accumulation)
if (this._sseListenerCleanup) { if (this._sseListenerCleanup) {
@@ -742,9 +728,7 @@ class CodemanApp {
this.eventSource = null; this.eventSource = null;
} }
// Clear any existing reconnect timeout before setting new one (prevents orphaned timeouts) // Clear any existing reconnect timeout before setting new one (prevents orphaned timeouts)
if (this.sseReconnectTimeout) { this._clearTimer('sseReconnectTimeout');
clearTimeout(this.sseReconnectTimeout);
}
// Exponential backoff: 200ms, 500ms, 1s, 2s, 4s, ... up to 30s // Exponential backoff: 200ms, 500ms, 1s, 2s, 4s, ... up to 30s
// Fast first retry (200ms) for server-restart case (COM deploy), // Fast first retry (200ms) for server-restart case (COM deploy),
// then ramp up for real network issues. // then ramp up for real network issues.
@@ -955,15 +939,7 @@ class CodemanApp {
if (data.id === this.activeSessionId) { if (data.id === this.activeSessionId) {
this.terminal.writeln(`\x1b[1;31m Error: ${data.error}\x1b[0m`); this.terminal.writeln(`\x1b[1;31m Error: ${data.error}\x1b[0m`);
} }
const session = this.sessions.get(data.id); this._notifySession(data.id, 'critical', 'session-error', 'Session Error', data.error || 'Unknown error');
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',
});
} }
_onSessionExit(data) { _onSessionExit(data) {
@@ -976,14 +952,7 @@ class CodemanApp {
} }
// Notify on unexpected exit (non-zero code) // Notify on unexpected exit (non-zero code)
if (data.code && data.code !== 0) { if (data.code && data.code !== 0) {
this.notificationManager?.notify({ this._notifySession(data.id, 'critical', 'session-crash', 'Session Crashed', `Exited with code ${data.code}`);
urgency: 'critical',
category: 'session-crash',
sessionId: data.id,
sessionName: session?.name || this.getShortId(data.id),
title: 'Session Crashed',
message: `Exited with code ${data.code}`,
});
} }
} }
@@ -1000,15 +969,7 @@ class CodemanApp {
const threshold = this.notificationManager?.preferences?.stuckThresholdMs || 600000; const threshold = this.notificationManager?.preferences?.stuckThresholdMs || 600000;
clearTimeout(this.idleTimers.get(data.id)); clearTimeout(this.idleTimers.get(data.id));
this.idleTimers.set(data.id, setTimeout(() => { this.idleTimers.set(data.id, setTimeout(() => {
const s = this.sessions.get(data.id); this._notifySession(data.id, 'warning', 'session-stuck', 'Session Idle', `Idle for ${Math.round(threshold / 60000)}+ minutes`);
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.idleTimers.delete(data.id); this.idleTimers.delete(data.id);
}, threshold)); }, threshold));
} }
@@ -1039,15 +1000,7 @@ class CodemanApp {
this.showToast(`Auto-cleared at ${data.tokens.toLocaleString()} tokens`, 'info'); this.showToast(`Auto-cleared at ${data.tokens.toLocaleString()} tokens`, 'info');
this.updateRespawnTokens(0); this.updateRespawnTokens(0);
} }
const session = this.sessions.get(data.sessionId); this._notifySession(data.sessionId, 'info', 'auto-clear', 'Auto-Cleared', `Context reset at ${(data.tokens || 0).toLocaleString()} tokens`);
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`,
});
} }
_onSessionCliInfo(data) { _onSessionCliInfo(data) {
@@ -1168,10 +1121,7 @@ class CodemanApp {
/** Close the active WebSocket connection (if any). */ /** Close the active WebSocket connection (if any). */
_disconnectWs() { _disconnectWs() {
if (this._wsReconnectTimer) { this._clearTimer('_wsReconnectTimer');
clearTimeout(this._wsReconnectTimer);
this._wsReconnectTimer = null;
}
this._wsReconnectAttempts = 0; this._wsReconnectAttempts = 0;
if (this._ws) { if (this._ws) {
this._ws.onclose = null; // Prevent re-entrant cleanup this._ws.onclose = null; // Prevent re-entrant cleanup
@@ -1317,35 +1267,12 @@ class CodemanApp {
if (!showCjk) window.cjkActive = false; if (!showCjk) window.cjkActive = false;
} }
handleInit(data) { /**
// Clear the init fallback timer since we got data * Reset all app state maps, timers, and handlers to a clean baseline.
if (this._initFallbackTimer) { * Called by handleInit() on SSE reconnect / page reload to prevent
clearTimeout(this._initFallbackTimer); * memory leaks and stale data.
this._initFallbackTimer = null; */
} _resetAllAppState() {
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.sessions.clear(); this.sessions.clear();
this.ralphStates.clear(); this.ralphStates.clear();
this.terminalBuffers.clear(); this.terminalBuffers.clear();
@@ -1359,17 +1286,11 @@ class CodemanApp {
} }
this.idleTimers.clear(); this.idleTimers.clear();
// Clear flicker filter state // Clear flicker filter state
if (this.flickerFilterTimeout) { this._clearTimer('flickerFilterTimeout');
clearTimeout(this.flickerFilterTimeout);
this.flickerFilterTimeout = null;
}
this.flickerFilterBuffer = ''; this.flickerFilterBuffer = '';
this.flickerFilterActive = false; this.flickerFilterActive = false;
// Clear pending terminal writes // Clear pending terminal writes
if (this.syncWaitTimeout) { this._clearTimer('syncWaitTimeout');
clearTimeout(this.syncWaitTimeout);
this.syncWaitTimeout = null;
}
this.pendingWrites = []; this.pendingWrites = [];
this.writeFrameScheduled = false; this.writeFrameScheduled = false;
this._isLoadingBuffer = false; this._isLoadingBuffer = false;
@@ -1428,6 +1349,36 @@ class CodemanApp {
clearInterval(this.runSummaryAutoRefreshTimer); clearInterval(this.runSummaryAutoRefreshTimer);
this.runSummaryAutoRefreshTimer = null; 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 => { data.sessions.forEach(s => {
this.sessions.set(s.id, s); this.sessions.set(s.id, s);
// Load ralph state from session data (only if not explicitly closed by user) // Load ralph state from session data (only if not explicitly closed by user)
@@ -2001,23 +1952,25 @@ class CodemanApp {
return this.getShortId(session.id); return this.getShortId(session.id);
} }
async selectSession(sessionId) { _notifySession(sessionId, urgency, category, title, message) {
if (this.activeSessionId === sessionId) return; const session = this.sessions.get(sessionId);
// Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors this.notificationManager?.notify({
// programmatic focus() within the user-gesture call stack (e.g. tab click). urgency,
// After the first await the gesture context is lost and focus() is silently category,
// ignored, leaving the keyboard unable to send input to the terminal. sessionId,
if (this.terminal) this.terminal.focus(); sessionName: session?.name || this.getShortId(sessionId),
title,
const _selStart = performance.now(); message,
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
/**
* 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) // Close WebSocket for previous session (new one opens after buffer load)
this._disconnectWs(); this._disconnectWs();
@@ -2026,10 +1979,7 @@ class CodemanApp {
if (cjkEl) cjkEl.value = ''; if (cjkEl) cjkEl.value = '';
// Clean up flicker filter state when switching sessions // Clean up flicker filter state when switching sessions
if (this.flickerFilterTimeout) { this._clearTimer('flickerFilterTimeout');
clearTimeout(this.flickerFilterTimeout);
this.flickerFilterTimeout = null;
}
this.flickerFilterBuffer = ''; this.flickerFilterBuffer = '';
this.flickerFilterActive = false; this.flickerFilterActive = false;
@@ -2037,14 +1987,11 @@ class CodemanApp {
this._tabCompletionSessionId = null; this._tabCompletionSessionId = null;
this._tabCompletionRetries = 0; this._tabCompletionRetries = 0;
this._tabCompletionBaseText = null; this._tabCompletionBaseText = null;
if (this._tabCompletionFallback) { clearTimeout(this._tabCompletionFallback); this._tabCompletionFallback = null; } this._clearTimer('_tabCompletionFallback');
if (this._clientDropRecoveryTimer) { clearTimeout(this._clientDropRecoveryTimer); this._clientDropRecoveryTimer = null; } this._clearTimer('_clientDropRecoveryTimer');
// Clean up pending terminal writes to prevent old session data from appearing in new session // Clean up pending terminal writes to prevent old session data from appearing in new session
if (this.syncWaitTimeout) { this._clearTimer('syncWaitTimeout');
clearTimeout(this.syncWaitTimeout);
this.syncWaitTimeout = null;
}
this.pendingWrites = []; this.pendingWrites = [];
this.writeFrameScheduled = false; this.writeFrameScheduled = false;
this._isLoadingBuffer = false; this._isLoadingBuffer = false;
@@ -2094,9 +2041,29 @@ class CodemanApp {
// Only sessions with prior flushed text (from tab-switch-away) need detection. // Only sessions with prior flushed text (from tab-switch-away) need detection.
// After the user's first Enter, clear() resets _bufferDetectDone = false, // After the user's first Enter, clear() resets _bufferDetectDone = false,
// re-enabling detection for tab completion and other legitimate cases. // 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(); 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; this.activeSessionId = sessionId;
try { localStorage.setItem('codeman-active-session', sessionId); } catch {} try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
this.hideWelcome(); this.hideWelcome();
@@ -2186,7 +2153,7 @@ class CodemanApp {
this.terminal.clear(); this.terminal.clear();
this.terminal.reset(); this.terminal.reset();
await this.chunkedTerminalWrite(cachedBuffer); 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(); this.terminal.scrollToBottom();
_crashDiag.log('CACHE_DONE'); _crashDiag.log('CACHE_DONE');
} else if (sessionIsBusy) { } else if (sessionIsBusy) {
@@ -2198,7 +2165,7 @@ class CodemanApp {
_crashDiag.log('FETCH_START'); _crashDiag.log('FETCH_START');
const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`); 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(); const data = await res.json();
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`); _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 // Use chunked write for large buffers to avoid UI jank
await this.chunkedTerminalWrite(data.terminalBuffer); 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 // Ensure terminal is scrolled to bottom after buffer load
this.terminal.scrollToBottom(); this.terminal.scrollToBottom();
} }
@@ -2624,9 +2591,7 @@ class CodemanApp {
updateTokens() { updateTokens() {
// Debounce at 200ms — token display is non-critical and shouldn't // Debounce at 200ms — token display is non-critical and shouldn't
// compete with input handling on the main thread // compete with input handling on the main thread
if (this._updateTokensTimeout) { this._clearTimer('_updateTokensTimeout');
clearTimeout(this._updateTokensTimeout);
}
this._updateTokensTimeout = setTimeout(() => { this._updateTokensTimeout = setTimeout(() => {
this._updateTokensTimeout = null; this._updateTokensTimeout = null;
this._updateTokensImmediate(); this._updateTokensImmediate();
+19
View File
@@ -384,6 +384,11 @@
<span>Agent Teams</span> <span>Agent Teams</span>
</label> </label>
<span class="form-hint">Enable experimental Agent Teams for new sessions in this case</span> <span class="form-hint">Enable experimental Agent Teams for new sessions in this case</span>
<label class="checkbox-inline" style="margin-top: 6px;">
<input type="checkbox" id="caseOpusContext1m" onchange="app.onCaseSettingChanged()">
<span>1M Opus Context</span>
</label>
<span class="form-hint">Use 1M token context window for new sessions</span>
</div> </div>
</div> </div>
<!-- Mobile-only case button + gear --> <!-- Mobile-only case button + gear -->
@@ -408,6 +413,11 @@
<span>Agent Teams</span> <span>Agent Teams</span>
</label> </label>
<span class="form-hint">Enable Agent Teams for new sessions</span> <span class="form-hint">Enable Agent Teams for new sessions</span>
<label class="checkbox-inline" style="margin-top: 6px;">
<input type="checkbox" id="caseOpusContext1mMobile" onchange="app.onCaseSettingChangedMobile()">
<span>1M Opus Context</span>
</label>
<span class="form-hint">Use 1M token context window</span>
</div> </div>
</div> </div>
@@ -1046,6 +1056,14 @@
</label> </label>
<span class="form-hint">Enable experimental Agent Teams for all new Claude sessions (disabled by default)</span> <span class="form-hint">Enable experimental Agent Teams for all new Claude sessions (disabled by default)</span>
</div> </div>
<div class="form-row form-row-switch">
<label>1M Opus Context</label>
<label class="switch">
<input type="checkbox" id="appSettingsOpusContext1m">
<span class="slider"></span>
</label>
<span class="form-hint">Use 1M token context window (model: opus[1m]) for all new sessions</span>
</div>
<!-- Nice Priority Section --> <!-- Nice Priority Section -->
<div class="form-section-header">Nice Priority</div> <div class="form-section-header">Nice Priority</div>
<div class="form-row form-row-switch"> <div class="form-row form-row-switch">
@@ -1067,6 +1085,7 @@
<div class="form-row"> <div class="form-row">
<label>Default Model</label> <label>Default Model</label>
<select id="appSettingsDefaultModel" class="form-select"> <select id="appSettingsDefaultModel" class="form-select">
<option value="">Default (CLI default)</option>
<option value="opus">Opus (Most capable)</option> <option value="opus">Opus (Most capable)</option>
<option value="sonnet">Sonnet (Balanced)</option> <option value="sonnet">Sonnet (Balanced)</option>
<option value="haiku">Haiku (Fast & cheap)</option> <option value="haiku">Haiku (Fast & cheap)</option>
+144 -47
View File
@@ -65,13 +65,14 @@ html.mobile-init .file-browser-panel {
max-height: calc(48px + var(--safe-area-top)); max-height: calc(48px + var(--safe-area-top));
} }
/* Add top margin to main content to account for fixed header */ /* Push ALL content below fixed header (not just .main) so banners
.main { (respawn, timer, orchestrator) between header and main are visible */
margin-top: 54px; .app {
padding-top: 54px;
} }
.ios-device .main { .ios-device .app {
margin-top: calc(54px + var(--safe-area-top)); padding-top: calc(54px + var(--safe-area-top));
} }
/* Font controls - smaller on tablet, visibility controlled by JS */ /* Font controls - smaller on tablet, visibility controlled by JS */
@@ -235,6 +236,37 @@ html.mobile-init .file-browser-panel {
font-size: 0.7rem; font-size: 0.7rem;
} }
/* ---- Respawn Banner: Tablet Optimizations ---- */
.respawn-banner {
padding: 0.25rem 0.5rem;
padding-left: calc(0.5rem + var(--safe-area-left));
padding-right: calc(0.5rem + var(--safe-area-right));
font-size: 0.65rem;
}
.respawn-compact-layout {
gap: 0.75rem;
}
.respawn-status-row1 {
gap: 0.4rem;
}
.respawn-action-log {
max-height: 2.6em;
font-size: 0.6rem;
}
.respawn-countdown-timer {
font-size: 0.55rem;
}
/* Respawn timer bar — slimmer on tablet */
.respawn-timer-bar {
width: 24px;
}
/* Show desktop voice button on tablet (hidden by max-width:1023px in styles.css, /* Show desktop voice button on tablet (hidden by max-width:1023px in styles.css,
mobile .btn-voice-mobile only shows at <430px) */ mobile .btn-voice-mobile only shows at <430px) */
.toolbar-center .btn-toolbar.btn-voice { .toolbar-center .btn-toolbar.btn-voice {
@@ -327,18 +359,22 @@ html.mobile-init .file-browser-panel {
max-height: calc(36px + var(--safe-area-top)); max-height: calc(36px + var(--safe-area-top));
} }
/* Add top margin to main content to account for fixed header, /* Push ALL content below fixed header (not just .main) so banners
and bottom padding for the fixed toolbar (40px) so terminal (respawn, timer, orchestrator) between header and main are visible.
content doesn't extend behind it. JS overrides paddingBottom Bottom padding for the fixed toolbar (40px) so terminal content
doesn't extend behind it. JS overrides paddingBottom on .main
when keyboard is visible, and resetLayout() clears the inline when keyboard is visible, and resetLayout() clears the inline
style to re-expose this CSS value. */ style to re-expose this CSS value. */
.main { .app {
margin-top: 42px; padding-top: 42px;
padding-bottom: calc(40px + var(--safe-area-bottom));
} }
.ios-device .main { .ios-device .app {
margin-top: calc(42px + var(--safe-area-top)); padding-top: calc(42px + var(--safe-area-top));
}
.main {
padding-bottom: calc(40px + var(--safe-area-bottom));
} }
/* iOS Safari: toolbar is pushed up by (100vh - --app-height) to clear the /* iOS Safari: toolbar is pushed up by (100vh - --app-height) to clear the
@@ -1213,31 +1249,70 @@ html.mobile-init .file-browser-panel {
/* ---- Respawn Banner: Mobile Optimizations ---- */ /* ---- Respawn Banner: Mobile Optimizations ---- */
.respawn-banner { .respawn-banner {
padding: 0.4rem 0.5rem; padding: 0.3rem 0.5rem;
padding-left: calc(0.5rem + var(--safe-area-left)); padding-left: calc(0.5rem + var(--safe-area-left));
padding-right: calc(0.5rem + var(--safe-area-right)); padding-right: calc(0.5rem + var(--safe-area-right));
font-size: 0.7rem; font-size: 0.65rem;
} }
/* Stack banner vertically on mobile — status on top, action log below */ /* Stack banner vertically on mobile — status on top, action log below */
.respawn-compact-layout { .respawn-compact-layout {
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.15rem;
} }
/* Let status column shrink to fit */ /* Let status column shrink to fit */
.respawn-status-col { .respawn-status-col {
flex-shrink: 1; flex-shrink: 1;
min-width: 0; min-width: 0;
gap: 0.1rem;
} }
/* Wrap status row items — tokens/timer may go to second line */ /* Row1: wrap allowed, key info prioritized via order */
.respawn-status-row1 { .respawn-status-row1 {
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.35rem; gap: 0.25rem;
row-gap: 0.1rem;
} }
/* Stop button — 44px touch target */ /* State label — smaller on phone, stays first */
.respawn-state {
font-size: 0.6rem;
padding: 0.05rem 0.3rem;
}
/* Confidence badge — compact */
.respawn-banner .detection-confidence {
font-size: 0.5rem;
padding: 0.02rem 0.2rem;
}
/* Cycles — compact */
.respawn-cycles {
font-size: 0.55rem;
}
/* Run timer — compact */
.respawn-timer {
font-size: 0.55rem;
padding: 0 0.25rem;
}
/* Tokens — compact, truncate if long */
.respawn-tokens {
font-size: 0.55rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 120px;
}
/* Spinner icon — smaller on phone */
.respawn-indicator {
font-size: 0.65rem;
}
/* Stop button — 36px touch target, pushed right */
.respawn-banner .btn-icon-only { .respawn-banner .btn-icon-only {
min-width: 36px; min-width: 36px;
min-height: 36px; min-height: 36px;
@@ -1247,26 +1322,50 @@ html.mobile-init .file-browser-panel {
justify-content: center; justify-content: center;
margin-left: auto; margin-left: auto;
border-radius: 6px; border-radius: 6px;
flex-shrink: 0;
} }
/* Action log — horizontal strip below status, border on top instead of left */ /* Row2: tighter spacing, smaller text */
.respawn-action-log { .respawn-status-row2 {
border-left: none; font-size: 0.55rem;
border-top: 1px solid rgba(34, 197, 94, 0.2); gap: 0.3rem;
padding-left: 0; min-height: 0;
padding-top: 0.25rem; flex-wrap: wrap;
max-height: 2em;
font-size: 0.6rem;
} }
/* Countdown timers — tighter on mobile */ /* Detection badges — smaller on phone */
.respawn-banner .detection-hook,
.respawn-banner .detection-ai-check,
.respawn-banner .detection-status {
font-size: 0.55rem;
padding: 0.02rem 0.2rem;
}
/* Countdown timers — inline, no progress bars */
.respawn-countdown-timers { .respawn-countdown-timers {
gap: 0.2rem; gap: 0.2rem;
} }
.respawn-countdown-timer { .respawn-countdown-timer {
font-size: 0.5rem;
padding: 0.02rem 0.2rem;
gap: 0.15rem;
}
/* Hide progress bars on phone — values are enough */
.respawn-countdown-timer .respawn-timer-bar {
display: none;
}
/* Action log — single-line strip below status, show latest entry only */
.respawn-action-log {
border-left: none;
border-top: 1px solid rgba(34, 197, 94, 0.15);
padding-left: 0;
padding-top: 0.15rem;
max-height: 1.4em;
font-size: 0.55rem; font-size: 0.55rem;
padding: 0.05rem 0.25rem; overflow: hidden;
} }
/* ---- Session Options Modal: Respawn Tab Mobile Optimizations ---- */ /* ---- Session Options Modal: Respawn Tab Mobile Optimizations ---- */
@@ -1292,28 +1391,27 @@ html.mobile-init .file-browser-panel {
border-radius: 5px; border-radius: 5px;
} }
/* Duration preset buttons — single row, all 7 items */ /* Duration preset buttons — grid layout, 4 columns for even spacing */
.duration-presets { .duration-presets {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: repeat(4, 1fr);
gap: 0.25rem; gap: 0.2rem;
} }
.duration-preset-btn { .duration-preset-btn {
min-height: 28px; min-height: 32px;
padding: 0.2rem 0.4rem; padding: 0.2rem 0.25rem;
font-size: 0.65rem; font-size: 0.65rem;
border-radius: 5px; border-radius: 5px;
text-align: center; text-align: center;
flex: 1 1 auto;
min-width: 0;
} }
/* Custom duration — inline with other presets */ /* Custom duration — spans full row below */
.duration-custom { .duration-custom {
flex-wrap: wrap; grid-column: 1 / -1;
display: flex;
gap: 0.25rem; gap: 0.25rem;
flex: 1 1 100%; align-items: center;
} }
.duration-custom .duration-preset-btn { .duration-custom .duration-preset-btn {
@@ -1331,24 +1429,23 @@ html.mobile-init .file-browser-panel {
font-size: 16px; /* Prevents iOS zoom */ font-size: 16px; /* Prevents iOS zoom */
} }
/* Preset selector row — compact inline */ /* Preset selector row — full-width dropdown, buttons below */
.preset-selector { .preset-selector {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: 1fr 1fr;
gap: 0.25rem; gap: 0.25rem;
} }
.preset-selector select { .preset-selector select {
width: 100%; grid-column: 1 / -1;
min-height: 28px; min-height: 32px;
font-size: 16px; /* Prevents iOS zoom */ font-size: 16px; /* Prevents iOS zoom */
border-radius: 5px; border-radius: 5px;
padding: 0.2rem 0.4rem; padding: 0.2rem 0.4rem;
} }
.preset-selector .btn { .preset-selector .btn {
flex: 1; min-height: 32px;
min-height: 28px;
font-size: 0.65rem; font-size: 0.65rem;
border-radius: 5px; border-radius: 5px;
padding: 0.2rem 0.4rem; padding: 0.2rem 0.4rem;
+13 -34
View File
@@ -14,6 +14,13 @@
*/ */
Object.assign(CodemanApp.prototype, { 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 // Tasks
_onTaskCreated(data) { _onTaskCreated(data) {
this.renderSessionTabs(); this.renderSessionTabs();
@@ -106,15 +113,7 @@ Object.assign(CodemanApp.prototype, {
// Notify about new subagent discovery // Notify about new subagent discovery
const parentId = this.subagentParentMap.get(data.agentId); const parentId = this.subagentParentMap.get(data.agentId);
const parentSession = parentId ? this.sessions.get(parentId) : null; this._notifySession(parentId || data.sessionId, 'info', 'subagent-spawn', 'Subagent Spawned', data.description || 'New background agent started');
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',
});
}, },
_onSubagentUpdated(data) { _onSubagentUpdated(data) {
@@ -135,10 +134,7 @@ Object.assign(CodemanApp.prototype, {
}, },
_onSubagentToolCall(data) { _onSubagentToolCall(data) {
const activity = this.subagentActivity.get(data.agentId) || []; this._addActivityEntry(data.agentId, { type: 'tool', ...data });
activity.push({ type: 'tool', ...data });
if (activity.length > 50) activity.shift(); // Keep last 50 entries
this.subagentActivity.set(data.agentId, activity);
if (this.activeSubagentId === data.agentId) { if (this.activeSubagentId === data.agentId) {
this.renderSubagentDetail(); this.renderSubagentDetail();
} }
@@ -150,10 +146,7 @@ Object.assign(CodemanApp.prototype, {
}, },
_onSubagentProgress(data) { _onSubagentProgress(data) {
const activity = this.subagentActivity.get(data.agentId) || []; this._addActivityEntry(data.agentId, { type: 'progress', ...data });
activity.push({ type: 'progress', ...data });
if (activity.length > 50) activity.shift();
this.subagentActivity.set(data.agentId, activity);
if (this.activeSubagentId === data.agentId) { if (this.activeSubagentId === data.agentId) {
this.renderSubagentDetail(); this.renderSubagentDetail();
} }
@@ -164,10 +157,7 @@ Object.assign(CodemanApp.prototype, {
}, },
_onSubagentMessage(data) { _onSubagentMessage(data) {
const activity = this.subagentActivity.get(data.agentId) || []; this._addActivityEntry(data.agentId, { type: 'message', ...data });
activity.push({ type: 'message', ...data });
if (activity.length > 50) activity.shift();
this.subagentActivity.set(data.agentId, activity);
if (this.activeSubagentId === data.agentId) { if (this.activeSubagentId === data.agentId) {
this.renderSubagentDetail(); this.renderSubagentDetail();
} }
@@ -190,10 +180,7 @@ Object.assign(CodemanApp.prototype, {
} }
// Add to activity stream // Add to activity stream
const activity = this.subagentActivity.get(data.agentId) || []; this._addActivityEntry(data.agentId, { type: 'tool_result', ...data });
activity.push({ type: 'tool_result', ...data });
if (activity.length > 50) activity.shift();
this.subagentActivity.set(data.agentId, activity);
if (this.activeSubagentId === data.agentId) { if (this.activeSubagentId === data.agentId) {
this.renderSubagentDetail(); this.renderSubagentDetail();
@@ -224,15 +211,7 @@ Object.assign(CodemanApp.prototype, {
// Notify about subagent completion // Notify about subagent completion
const parentId = this.subagentParentMap.get(data.agentId); const parentId = this.subagentParentMap.get(data.agentId);
const parentSession = parentId ? this.sessions.get(parentId) : null; this._notifySession(parentId || existing?.sessionId || data.sessionId, 'info', 'subagent-complete', 'Subagent Completed', existing?.description || data.description || 'Background agent finished');
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',
});
// Clean up activity/tool data for completed agents after 5 minutes // Clean up activity/tool data for completed agents after 5 minutes
// This prevents memory leaks from long-running sessions with many subagents // 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); this.updateRalphState(data.sessionId, existing);
} }
const session = this.sessions.get(data.sessionId); this._notifySession(data.sessionId, 'warning', 'ralph-complete', 'Loop Complete', `Completion: ${data.phrase || 'unknown'}`);
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'}`,
});
}, },
_onRalphStatusUpdate(data) { _onRalphStatusUpdate(data) {
@@ -68,28 +60,12 @@ Object.assign(CodemanApp.prototype, {
this.updateRalphState(data.sessionId, { circuitBreaker: data.status }); this.updateRalphState(data.sessionId, { circuitBreaker: data.status });
// Notify if circuit breaker opens // Notify if circuit breaker opens
if (data.status.state === 'OPEN') { if (data.status.state === 'OPEN') {
const session = this.sessions.get(data.sessionId); this._notifySession(data.sessionId, 'critical', 'circuit-breaker', 'Circuit Breaker Open', data.status.reason || 'Loop stuck - no progress detected');
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',
});
} }
}, },
_onExitGateMet(data) { _onExitGateMet(data) {
const session = this.sessions.get(data.sessionId); this._notifySession(data.sessionId, 'warning', 'exit-gate', 'Exit Gate Met', `Loop ready to exit (indicators: ${data.completionIndicators})`);
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})`,
});
}, },
// Bash tools // Bash tools
+3 -26
View File
@@ -44,21 +44,13 @@ Object.assign(CodemanApp.prototype, {
}, },
_onRespawnBlocked(data) { _onRespawnBlocked(data) {
const session = this.sessions.get(data.sessionId);
const reasonMap = { const reasonMap = {
circuit_breaker_open: 'Circuit Breaker Open', circuit_breaker_open: 'Circuit Breaker Open',
exit_signal: 'Exit Signal Detected', exit_signal: 'Exit Signal Detected',
status_blocked: 'Claude Reported BLOCKED', status_blocked: 'Claude Reported BLOCKED',
}; };
const title = reasonMap[data.reason] || 'Respawn Blocked'; const title = reasonMap[data.reason] || 'Respawn Blocked';
this.notificationManager?.notify({ this._notifySession(data.sessionId, 'critical', 'respawn-blocked', title, data.details);
urgency: 'critical',
category: 'respawn-blocked',
sessionId: data.sessionId,
sessionName: session?.name || this.getShortId(data.sessionId),
title,
message: data.details,
});
// Update respawn panel to show blocked state // Update respawn panel to show blocked state
if (data.sessionId === this.activeSessionId) { if (data.sessionId === this.activeSessionId) {
const stateEl = document.getElementById('respawnStateLabel'); const stateEl = document.getElementById('respawnStateLabel');
@@ -71,14 +63,7 @@ Object.assign(CodemanApp.prototype, {
_onRespawnAutoAcceptSent(data) { _onRespawnAutoAcceptSent(data) {
const session = this.sessions.get(data.sessionId); const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({ this._notifySession(data.sessionId, 'info', 'auto-accept', 'Plan Accepted', `Accepted plan mode for ${session?.name || 'session'}`);
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'}`,
});
}, },
_onRespawnDetectionUpdate(data) { _onRespawnDetectionUpdate(data) {
@@ -144,15 +129,7 @@ Object.assign(CodemanApp.prototype, {
}, },
_onRespawnError(data) { _onRespawnError(data) {
const session = this.sessions.get(data.sessionId); this._notifySession(data.sessionId, 'critical', 'session-error', 'Respawn Error', data.error || data.message || 'Respawn encountered an error');
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',
});
}, },
_onRespawnActionLog(data) { _onRespawnActionLog(data) {
+19 -11
View File
@@ -109,16 +109,10 @@ Object.assign(CodemanApp.prototype, {
async saveLastUsedCase(caseName) { async saveLastUsedCase(caseName) {
try { 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', { await fetch('/api/settings', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings) body: JSON.stringify({ lastUsedCase: caseName })
}); });
} catch (err) { } catch (err) {
console.error('Failed to save last used case:', err); console.error('Failed to save last used case:', err);
@@ -330,6 +324,8 @@ Object.assign(CodemanApp.prototype, {
envOverrides.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1'; envOverrides.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
} }
const hasEnvOverrides = Object.keys(envOverrides).length > 0; const hasEnvOverrides = Object.keys(envOverrides).length > 0;
const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled;
const modelOverride = useOpus1m ? 'opus[1m]' : '';
// Step 1: Create all sessions in parallel // Step 1: Create all sessions in parallel
this.terminal.writeln(`\x1b[90m Creating ${tabCount} session(s)...\x1b[0m`); this.terminal.writeln(`\x1b[90m Creating ${tabCount} session(s)...\x1b[0m`);
@@ -337,7 +333,11 @@ Object.assign(CodemanApp.prototype, {
fetch('/api/sessions', { fetch('/api/sessions', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir, name, ...(hasEnvOverrides ? { envOverrides } : {}) }) body: JSON.stringify({
workingDir, name,
...(hasEnvOverrides ? { envOverrides } : {}),
...(modelOverride !== undefined ? { modelOverride } : {}),
})
}).then(r => r.json()) }).then(r => r.json())
); );
const createResults = await Promise.all(createPromises); const createResults = await Promise.all(createPromises);
@@ -919,6 +919,7 @@ Object.assign(CodemanApp.prototype, {
const caseName = document.getElementById('quickStartCase').value || 'testcase'; const caseName = document.getElementById('quickStartCase').value || 'testcase';
const settings = this.getCaseSettings(caseName); const settings = this.getCaseSettings(caseName);
document.getElementById('caseAgentTeams').checked = settings.agentTeams; document.getElementById('caseAgentTeams').checked = settings.agentTeams;
document.getElementById('caseOpusContext1m').checked = settings.opusContext1m;
popover.classList.remove('hidden'); popover.classList.remove('hidden');
// Close on outside click (one-shot listener) // Close on outside click (one-shot listener)
@@ -940,7 +941,7 @@ Object.assign(CodemanApp.prototype, {
const stored = localStorage.getItem('caseSettings_' + caseName); const stored = localStorage.getItem('caseSettings_' + caseName);
if (stored) return JSON.parse(stored); if (stored) return JSON.parse(stored);
} catch { /* ignore */ } } catch { /* ignore */ }
return { agentTeams: false }; return { agentTeams: false, opusContext1m: true };
}, },
saveCaseSettings(caseName, settings) { saveCaseSettings(caseName, settings) {
@@ -951,10 +952,13 @@ Object.assign(CodemanApp.prototype, {
const caseName = document.getElementById('quickStartCase').value || 'testcase'; const caseName = document.getElementById('quickStartCase').value || 'testcase';
const settings = this.getCaseSettings(caseName); const settings = this.getCaseSettings(caseName);
settings.agentTeams = document.getElementById('caseAgentTeams').checked; settings.agentTeams = document.getElementById('caseAgentTeams').checked;
settings.opusContext1m = document.getElementById('caseOpusContext1m').checked;
this.saveCaseSettings(caseName, settings); this.saveCaseSettings(caseName, settings);
// Sync mobile checkbox // Sync mobile checkboxes
const mobileCheckbox = document.getElementById('caseAgentTeamsMobile'); const mobileCheckbox = document.getElementById('caseAgentTeamsMobile');
if (mobileCheckbox) mobileCheckbox.checked = settings.agentTeams; if (mobileCheckbox) mobileCheckbox.checked = settings.agentTeams;
const mobileOpusCheckbox = document.getElementById('caseOpusContext1mMobile');
if (mobileOpusCheckbox) mobileOpusCheckbox.checked = settings.opusContext1m;
}, },
toggleCaseSettingsMobile() { toggleCaseSettingsMobile() {
@@ -963,6 +967,7 @@ Object.assign(CodemanApp.prototype, {
const caseName = document.getElementById('quickStartCase').value || 'testcase'; const caseName = document.getElementById('quickStartCase').value || 'testcase';
const settings = this.getCaseSettings(caseName); const settings = this.getCaseSettings(caseName);
document.getElementById('caseAgentTeamsMobile').checked = settings.agentTeams; document.getElementById('caseAgentTeamsMobile').checked = settings.agentTeams;
document.getElementById('caseOpusContext1mMobile').checked = settings.opusContext1m;
popover.classList.remove('hidden'); popover.classList.remove('hidden');
const closeHandler = (e) => { const closeHandler = (e) => {
@@ -981,10 +986,13 @@ Object.assign(CodemanApp.prototype, {
const caseName = document.getElementById('quickStartCase').value || 'testcase'; const caseName = document.getElementById('quickStartCase').value || 'testcase';
const settings = this.getCaseSettings(caseName); const settings = this.getCaseSettings(caseName);
settings.agentTeams = document.getElementById('caseAgentTeamsMobile').checked; settings.agentTeams = document.getElementById('caseAgentTeamsMobile').checked;
settings.opusContext1m = document.getElementById('caseOpusContext1mMobile').checked;
this.saveCaseSettings(caseName, settings); this.saveCaseSettings(caseName, settings);
// Sync desktop checkbox // Sync desktop checkboxes
const desktopCheckbox = document.getElementById('caseAgentTeams'); const desktopCheckbox = document.getElementById('caseAgentTeams');
if (desktopCheckbox) desktopCheckbox.checked = settings.agentTeams; if (desktopCheckbox) desktopCheckbox.checked = settings.agentTeams;
const desktopOpusCheckbox = document.getElementById('caseOpusContext1m');
if (desktopOpusCheckbox) desktopOpusCheckbox.checked = settings.opusContext1m;
}, },
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
+22 -78
View File
@@ -14,92 +14,46 @@
Object.assign(CodemanApp.prototype, { Object.assign(CodemanApp.prototype, {
// Hooks (Claude Code hook events) // Hooks (Claude Code hook events)
_onHookIdlePrompt(data) { _onHookIdlePrompt(data) {
const session = this.sessions.get(data.sessionId);
// Always track pending hook - alert will show when switching away from session // Always track pending hook - alert will show when switching away from session
if (data.sessionId) { if (data.sessionId) {
this.setPendingHook(data.sessionId, 'idle_prompt'); this.setPendingHook(data.sessionId, 'idle_prompt');
} }
this.notificationManager?.notify({ this._notifySession(data.sessionId, 'warning', 'hook-idle', 'Waiting for Input', data.message || 'Claude is idle and waiting for a prompt');
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',
});
}, },
_onHookPermissionPrompt(data) { _onHookPermissionPrompt(data) {
const session = this.sessions.get(data.sessionId);
// Always track pending hook - action alerts need user interaction to clear // Always track pending hook - action alerts need user interaction to clear
if (data.sessionId) { if (data.sessionId) {
this.setPendingHook(data.sessionId, 'permission_prompt'); this.setPendingHook(data.sessionId, 'permission_prompt');
} }
const toolInfo = data.tool ? `${data.tool}${data.command ? ': ' + data.command : data.file ? ': ' + data.file : ''}` : ''; const toolInfo = data.tool ? `${data.tool}${data.command ? ': ' + data.command : data.file ? ': ' + data.file : ''}` : '';
this.notificationManager?.notify({ this._notifySession(data.sessionId, 'critical', 'hook-permission', 'Permission Required', toolInfo || 'Claude needs tool approval to continue');
urgency: 'critical',
category: 'hook-permission',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId,
title: 'Permission Required',
message: toolInfo || 'Claude needs tool approval to continue',
});
}, },
_onHookElicitationDialog(data) { _onHookElicitationDialog(data) {
const session = this.sessions.get(data.sessionId);
// Always track pending hook - action alerts need user interaction to clear // Always track pending hook - action alerts need user interaction to clear
if (data.sessionId) { if (data.sessionId) {
this.setPendingHook(data.sessionId, 'elicitation_dialog'); this.setPendingHook(data.sessionId, 'elicitation_dialog');
} }
this.notificationManager?.notify({ this._notifySession(data.sessionId, 'critical', 'hook-elicitation', 'Question Asked', data.question || 'Claude is asking a question and waiting for your answer');
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',
});
}, },
_onHookStop(data) { _onHookStop(data) {
const session = this.sessions.get(data.sessionId);
// Clear all pending hooks when Claude finishes responding // Clear all pending hooks when Claude finishes responding
if (data.sessionId) { if (data.sessionId) {
this.clearPendingHooks(data.sessionId); this.clearPendingHooks(data.sessionId);
} }
this.notificationManager?.notify({ this._notifySession(data.sessionId, 'info', 'hook-stop', 'Response Complete', data.reason || 'Claude has finished responding');
urgency: 'info',
category: 'hook-stop',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId,
title: 'Response Complete',
message: data.reason || 'Claude has finished responding',
});
}, },
_onHookTeammateIdle(data) { _onHookTeammateIdle(data) {
const session = this.sessions.get(data.sessionId); const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({ this._notifySession(data.sessionId, 'warning', 'hook-teammate-idle', 'Teammate Idle', `A teammate is idle in ${session?.name || data.sessionId}`);
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}`,
});
}, },
_onHookTaskCompleted(data) { _onHookTaskCompleted(data) {
const session = this.sessions.get(data.sessionId); const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({ this._notifySession(data.sessionId, 'info', 'hook-task-completed', 'Task Completed', `A team task completed in ${session?.name || data.sessionId}`);
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}`,
});
}, },
@@ -378,6 +332,7 @@ Object.assign(CodemanApp.prototype, {
}; };
// Claude Permissions settings // Claude Permissions settings
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false; document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
// CPU Priority settings // CPU Priority settings
const niceSettings = settings.nice || {}; const niceSettings = settings.nice || {};
document.getElementById('appSettingsNiceEnabled').checked = niceSettings.enabled ?? false; document.getElementById('appSettingsNiceEnabled').checked = niceSettings.enabled ?? false;
@@ -517,16 +472,17 @@ Object.assign(CodemanApp.prototype, {
} }
}, },
_updateTunnelUrlDisplay(url) { _updateTunnelUrlRow(rowId, displayId, url, suffix = '') {
const row = document.getElementById('tunnelUrlRow'); const row = document.getElementById(rowId);
const display = document.getElementById('tunnelUrlDisplay'); const display = document.getElementById(displayId);
if (!row || !display) return; if (!row || !display) return;
if (url) { if (url) {
const fullUrl = url + suffix;
row.style.display = ''; row.style.display = '';
display.textContent = url; display.textContent = fullUrl;
display.onclick = () => { display.onclick = () => {
navigator.clipboard.writeText(url).then(() => { navigator.clipboard.writeText(fullUrl).then(() => {
this.showToast('Tunnel URL copied', 'success'); this.showToast(`${suffix ? 'Upload' : 'Tunnel'} URL copied`, 'success');
}); });
}; };
} else { } else {
@@ -534,24 +490,11 @@ Object.assign(CodemanApp.prototype, {
display.textContent = ''; display.textContent = '';
display.onclick = null; display.onclick = null;
} }
// Upload URL row },
const uploadRow = document.getElementById('tunnelUploadUrlRow');
const uploadDisplay = document.getElementById('tunnelUploadUrlDisplay'); _updateTunnelUrlDisplay(url) {
if (!uploadRow || !uploadDisplay) return; this._updateTunnelUrlRow('tunnelUrlRow', 'tunnelUrlDisplay', url);
if (url) { this._updateTunnelUrlRow('tunnelUploadUrlRow', 'tunnelUploadUrlDisplay', url, '/upload.html');
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;
}
}, },
showTunnelQR() { showTunnelQR() {
@@ -1188,6 +1131,7 @@ Object.assign(CodemanApp.prototype, {
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(), allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
// Claude Permissions settings // Claude Permissions settings
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked, agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
// CPU Priority settings // CPU Priority settings
nice: { nice: {
enabled: document.getElementById('appSettingsNiceEnabled').checked, enabled: document.getElementById('appSettingsNiceEnabled').checked,
@@ -1334,7 +1278,7 @@ Object.assign(CodemanApp.prototype, {
// Default model // Default model
const defaultModelEl = document.getElementById('appSettingsDefaultModel'); const defaultModelEl = document.getElementById('appSettingsDefaultModel');
if (defaultModelEl) { if (defaultModelEl) {
defaultModelEl.value = config.defaultModel || 'opus'; defaultModelEl.value = config.defaultModel || '';
} }
// Show recommendations // Show recommendations
const showRecsEl = document.getElementById('appSettingsShowModelRecommendations'); const showRecsEl = document.getElementById('appSettingsShowModelRecommendations');
@@ -1373,7 +1317,7 @@ Object.assign(CodemanApp.prototype, {
if (reviewEl?.value) agentTypeOverrides.review = reviewEl.value; if (reviewEl?.value) agentTypeOverrides.review = reviewEl.value;
const config = { const config = {
defaultModel: defaultModelEl?.value || 'opus', defaultModel: defaultModelEl?.value || '',
showRecommendations: showRecsEl?.checked ?? true, showRecommendations: showRecsEl?.checked ?? true,
agentTypeOverrides, agentTypeOverrides,
}; };
+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 { join, resolve, relative, isAbsolute } from 'node:path';
import { realpathSync } from 'node:fs';
import fs from 'node:fs/promises';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import type { z } from 'zod'; import type { z } from 'zod';
import { Session } from '../session.js'; import { Session } from '../session.js';
@@ -33,6 +35,45 @@ export function validatePathWithinBase(name: string, baseDir: string): string |
return fullPath; 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) // Maximum hook data size (prevents oversized SSE broadcasts)
const MAX_HOOK_DATA_SIZE = 8 * 1024; const MAX_HOOK_DATA_SIZE = 8 * 1024;
@@ -144,6 +185,26 @@ export function sanitizeHookData(data: Record<string, unknown> | null | undefine
return safeFields; 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. * 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 { CreateCaseSchema, LinkCaseSchema } from '../schemas.js';
import { generateClaudeMd } from '../../templates/claude-md.js'; import { generateClaudeMd } from '../../templates/claude-md.js';
import { writeHooksConfig } from '../../hooks-config.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 { SseEvent } from '../sse-events.js';
import type { EventPort, ConfigPort } from '../ports/index.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. */ /** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
async function readLinkedCases(): Promise<Record<string, string>> { async function readLinkedCases(): Promise<Record<string, string>> {
try { return readJsonConfig<Record<string, string>>(LINKED_CASES_FILE, 'linked cases', {});
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 {};
}
} }
/** Resolve a case name to its directory path, checking linked cases first, then CASES_DIR. */ /** 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 { FastifyInstance } from 'fastify';
import { join, resolve, relative, isAbsolute } from 'node:path'; import { join } from 'node:path';
import { realpathSync } from 'node:fs';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { fileStreamManager } from '../../file-stream-manager.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'; import type { SessionPort } from '../ports/index.js';
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void { 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) // Validate path is within working directory (security: resolve symlinks to prevent traversal)
const fullPath = resolve(session.workingDir, filePath); const validated = validateSessionFilePath(session.workingDir, filePath);
let resolvedPath: string; if (!validated) {
try {
resolvedPath = realpathSync(fullPath);
} catch {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'); return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found');
} }
const relativePath = relative(session.workingDir, resolvedPath); const { resolvedPath } = validated;
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory');
}
try { try {
const stat = await fs.stat(resolvedPath); 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) // Validate path is within working directory (security: resolve symlinks to prevent traversal)
const fullPath = resolve(session.workingDir, filePath); const validated = validateSessionFilePath(session.workingDir, filePath);
let resolvedPath: string; if (!validated) {
try {
resolvedPath = realpathSync(fullPath);
} catch {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found')); reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return; return;
} }
const relativePath = relative(session.workingDir, resolvedPath); const { resolvedPath } = validated;
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory'));
return;
}
try { try {
// Validate file size before reading (DoS protection - prevent memory exhaustion) // 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; 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; let forwardingLoop: import('../../orchestrator-loop.js').OrchestratorLoop | null = null;
function setupEventForwarding(loop: import('../../orchestrator-loop.js').OrchestratorLoop) { function setupEventForwarding(loop: import('../../orchestrator-loop.js').OrchestratorLoop) {
if (forwardingLoop === loop) return; // Already attached to this loop instance if (forwardingLoop === loop) return; // Already attached to this loop instance
forwardingLoop = loop; forwardingLoop = loop;
loop.on('stateChanged', (state, prevState) => { for (const [event, sseEvent, argNames] of EVENT_MAP) {
ctx.broadcast(SseEvent.OrchestratorStateChanged, { state, prevState }); // eslint-disable-next-line @typescript-eslint/no-explicit-any
}); loop.on(event, (...args: any[]) => {
loop.on('planProgress', (phase, detail) => { const payload: Record<string, unknown> = {};
ctx.broadcast(SseEvent.OrchestratorPlanProgress, { phase, detail }); argNames.forEach((name, i) => {
}); payload[name] = args[i];
loop.on('planReady', (plan) => { });
ctx.broadcast(SseEvent.OrchestratorPlanReady, { plan }); ctx.broadcast(sseEvent, payload);
}); });
loop.on('phaseStarted', (phase) => { }
ctx.broadcast(SseEvent.OrchestratorPhaseStarted, { phase }); // Special cases with non-trivial payload transforms
});
loop.on('phaseCompleted', (phase) => {
ctx.broadcast(SseEvent.OrchestratorPhaseCompleted, { phase });
});
loop.on('phaseFailed', (phase, reason) => {
ctx.broadcast(SseEvent.OrchestratorPhaseFailed, { phase, reason });
});
loop.on('verificationResult', (phase, result) => { loop.on('verificationResult', (phase, result) => {
ctx.broadcast(SseEvent.OrchestratorVerification, { phaseId: phase.id, 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) => { loop.on('error', (error) => {
ctx.broadcast(SseEvent.OrchestratorError, { error: error.message }); ctx.broadcast(SseEvent.OrchestratorError, { error: error.message });
}); });
+1 -1
View File
@@ -308,7 +308,7 @@ export function registerRalphRoutes(
useMux: true, useMux: true,
mode: 'claude', mode: 'claude',
niceConfig, niceConfig,
model: rlModelConfig?.defaultModel, model: rlModelConfig?.defaultModel || undefined,
claudeMode: rlClaudeModeConfig.claudeMode, claudeMode: rlClaudeModeConfig.claudeMode,
allowedTools: rlClaudeModeConfig.allowedTools, allowedTools: rlClaudeModeConfig.allowedTools,
}); });
+16 -3
View File
@@ -42,7 +42,7 @@ import {
validatePathWithinBase, validatePathWithinBase,
} from '../route-helpers.js'; } from '../route-helpers.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
import { writeHooksConfig, updateCaseEnvVars } from '../../hooks-config.js'; import { writeHooksConfig, updateCaseEnvVars, updateCaseModel } from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js'; import { generateClaudeMd } from '../../templates/claude-md.js';
import { imageWatcher } from '../../image-watcher.js'; import { imageWatcher } from '../../image-watcher.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js';
@@ -166,6 +166,11 @@ export function registerSessionRoutes(
await updateCaseEnvVars(workingDir, body.envOverrides); await updateCaseEnvVars(workingDir, body.envOverrides);
} }
// Write model override to .claude/settings.local.json if provided
if (body.modelOverride !== undefined) {
await updateCaseModel(workingDir, body.modelOverride || null);
}
// Check OpenCode availability if requested // Check OpenCode availability if requested
if (body.mode === 'opencode') { if (body.mode === 'opencode') {
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js'); const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
@@ -181,7 +186,11 @@ export function registerSessionRoutes(
const modelConfig = await ctx.getModelConfig(); const modelConfig = await ctx.getModelConfig();
const mode = body.mode || 'claude'; const mode = body.mode || 'claude';
const model = const model =
mode === 'opencode' ? body.openCodeConfig?.model : mode !== 'shell' ? modelConfig?.defaultModel : undefined; mode === 'opencode'
? body.openCodeConfig?.model
: mode !== 'shell'
? modelConfig?.defaultModel || undefined
: undefined;
const claudeModeConfig = await ctx.getClaudeModeConfig(); const claudeModeConfig = await ctx.getClaudeModeConfig();
const session = new Session({ const session = new Session({
workingDir, workingDir,
@@ -761,7 +770,11 @@ export function registerSessionRoutes(
const niceConfig = await ctx.getGlobalNiceConfig(); const niceConfig = await ctx.getGlobalNiceConfig();
const qsModelConfig = await ctx.getModelConfig(); const qsModelConfig = await ctx.getModelConfig();
const qsModel = const qsModel =
mode === 'opencode' ? openCodeConfig?.model : mode !== 'shell' ? qsModelConfig?.defaultModel : undefined; mode === 'opencode'
? openCodeConfig?.model
: mode !== 'shell'
? qsModelConfig?.defaultModel || undefined
: undefined;
const qsClaudeModeConfig = await ctx.getClaudeModeConfig(); const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
const session = new Session({ const session = new Session({
workingDir: casePath, workingDir: casePath,
+25 -65
View File
@@ -24,7 +24,14 @@ import {
import { subagentWatcher } from '../../subagent-watcher.js'; import { subagentWatcher } from '../../subagent-watcher.js';
import { imageWatcher } from '../../image-watcher.js'; import { imageWatcher } from '../../image-watcher.js';
import { getLifecycleLog } from '../../session-lifecycle-log.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 { SseEvent } from '../sse-events.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
@@ -281,15 +288,20 @@ export function registerSystemRoutes(
// ========== Stats ========== // ========== Stats ==========
app.get('/api/stats', async () => { function collectActiveTokens(): Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> {
const activeSessionTokens: 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) { for (const [sessionId, session] of ctx.sessions) {
activeSessionTokens[sessionId] = { tokens[sessionId] = {
inputTokens: session.inputTokens, inputTokens: session.inputTokens,
outputTokens: session.outputTokens, outputTokens: session.outputTokens,
totalCost: session.totalCost, totalCost: session.totalCost,
}; };
} }
return tokens;
}
app.get('/api/stats', async () => {
const activeSessionTokens = collectActiveTokens();
return { return {
success: true, success: true,
stats: ctx.store.getAggregateStats(activeSessionTokens), stats: ctx.store.getAggregateStats(activeSessionTokens),
@@ -298,14 +310,7 @@ export function registerSystemRoutes(
}); });
app.get('/api/token-stats', async () => { app.get('/api/token-stats', async () => {
const activeSessionTokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {}; const activeSessionTokens = collectActiveTokens();
for (const [sessionId, session] of ctx.sessions) {
activeSessionTokens[sessionId] = {
inputTokens: session.inputTokens,
outputTokens: session.outputTokens,
totalCost: session.totalCost,
};
}
return { return {
success: true, success: true,
daily: ctx.store.getDailyStats(30), daily: ctx.store.getDailyStats(30),
@@ -393,15 +398,7 @@ export function registerSystemRoutes(
// ========== Settings ========== // ========== Settings ==========
app.get('/api/settings', async () => { app.get('/api/settings', async () => {
try { return readJsonConfig(SETTINGS_PATH, 'settings', {});
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 {};
}); });
app.put('/api/settings', async (req) => { app.put('/api/settings', async (req) => {
@@ -422,30 +419,17 @@ export function registerSystemRoutes(
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2)); await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
// Handle subagent tracking toggle dynamically // Handle subagent tracking toggle dynamically
const subagentEnabled = settings.subagentTrackingEnabled ?? true; toggleService((settings.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
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');
}
// Handle image watcher toggle dynamically // Handle image watcher toggle dynamically
const imageWatcherEnabled = settings.imageWatcherEnabled ?? false; toggleService((settings.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
if (imageWatcherEnabled && !imageWatcher.isRunning()) {
imageWatcher.start();
// Re-watch all active sessions that have image watcher enabled // Re-watch all active sessions that have image watcher enabled
for (const session of ctx.sessions.values()) { for (const session of ctx.sessions.values()) {
if (session.imageWatcherEnabled) { if (session.imageWatcherEnabled) {
imageWatcher.watchSession(session.id, session.workingDir); 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 // Handle tunnel toggle dynamically
if ('tunnelEnabled' in settings) { if ('tunnelEnabled' in settings) {
@@ -472,16 +456,8 @@ export function registerSystemRoutes(
// ========== Model Configuration ========== // ========== Model Configuration ==========
app.get('/api/execution/model-config', async () => { app.get('/api/execution/model-config', async () => {
try { const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'model config', {});
const content = await fs.readFile(SETTINGS_PATH, 'utf-8'); return { success: true, data: settings.modelConfig || {} };
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: {} };
}
}); });
app.put('/api/execution/model-config', async (req) => { app.put('/api/execution/model-config', async (req) => {
@@ -545,15 +521,7 @@ export function registerSystemRoutes(
// ========== Subagent Window State Persistence ========== // ========== Subagent Window State Persistence ==========
app.get('/api/subagent-window-states', async () => { app.get('/api/subagent-window-states', async () => {
try { return readJsonConfig(windowStatesPath, 'subagent window states', { minimized: {}, open: [] });
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: [] };
}); });
app.put('/api/subagent-window-states', async (req) => { app.put('/api/subagent-window-states', async (req) => {
@@ -573,15 +541,7 @@ export function registerSystemRoutes(
// ========== Subagent Parent Associations ========== // ========== Subagent Parent Associations ==========
app.get('/api/subagent-parents', async () => { app.get('/api/subagent-parents', async () => {
try { return readJsonConfig(parentMapPath, 'subagent parent map', {});
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 {};
}); });
app.put('/api/subagent-parents', async (req) => { app.put('/api/subagent-parents', async (req) => {
+2
View File
@@ -124,6 +124,8 @@ export const CreateSessionSchema = z.object({
mode: z.enum(['claude', 'shell', 'opencode']).optional(), mode: z.enum(['claude', 'shell', 'opencode']).optional(),
name: z.string().max(100).optional(), name: z.string().max(100).optional(),
envOverrides: safeEnvOverridesSchema, envOverrides: safeEnvOverridesSchema,
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
modelOverride: z.string().max(50).optional(),
openCodeConfig: OpenCodeConfigSchema, openCodeConfig: OpenCodeConfigSchema,
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */ /** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
resumeSessionId: z resumeSessionId: z
+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();
}
}
+14 -12
View File
@@ -17,6 +17,7 @@ export class MockSession extends EventEmitter {
status: 'idle' | 'working' = 'idle'; status: 'idle' | 'working' = 'idle';
pid: number = 12345; pid: number = 12345;
isWorking: boolean = false; isWorking: boolean = false;
private _activeChildProcesses: { pid: number; command: string }[] = [];
ralphTracker: null = null; ralphTracker: null = null;
writeBuffer: string[] = []; writeBuffer: string[] = [];
terminalBuffer: string = ''; terminalBuffer: string = '';
@@ -52,9 +53,7 @@ export class MockSession extends EventEmitter {
/** Check if a specific command was written */ /** Check if a specific command was written */
hasWritten(pattern: string | RegExp): boolean { hasWritten(pattern: string | RegExp): boolean {
return this.writeBuffer.some((data) => return this.writeBuffer.some((data) => (typeof pattern === 'string' ? data.includes(pattern) : pattern.test(data)));
typeof pattern === 'string' ? data.includes(pattern) : pattern.test(data),
);
} }
// ========== Terminal Output Simulation ========== // ========== Terminal Output Simulation ==========
@@ -113,10 +112,7 @@ export class MockSession extends EventEmitter {
*/ */
simulatePlanModePrompt(): void { simulatePlanModePrompt(): void {
this.simulateTerminalOutput( this.simulateTerminalOutput(
'Would you like to proceed with this plan?\n' + 'Would you like to proceed with this plan?\n' + '\u276f 1. Yes\n' + ' 2. No\n' + ' 3. Type your own\n'
'\u276f 1. Yes\n' +
' 2. No\n' +
' 3. Type your own\n',
); );
} }
@@ -131,11 +127,7 @@ export class MockSession extends EventEmitter {
/** Simulate token count display */ /** Simulate token count display */
simulateTokenCount(tokens: number | string): void { simulateTokenCount(tokens: number | string): void {
const formatted = const formatted =
typeof tokens === 'number' typeof tokens === 'number' ? (tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens)) : tokens;
? tokens >= 1000
? `${(tokens / 1000).toFixed(1)}k`
: String(tokens)
: tokens;
this.simulateTerminalOutput(`${formatted} tokens used`); this.simulateTerminalOutput(`${formatted} tokens used`);
} }
@@ -167,6 +159,16 @@ export class MockSession extends EventEmitter {
return this._muxName; return this._muxName;
} }
/** Check for active child processes (mock returns configurable list) */
getActiveChildProcesses(): { pid: number; command: string }[] {
return this._activeChildProcesses;
}
/** Set active child processes for testing */
setActiveChildProcesses(processes: { pid: number; command: string }[]): void {
this._activeChildProcesses = processes;
}
// ========== Route-test conveniences ========== // ========== Route-test conveniences ==========
/** Session display name */ /** Session display name */
+30
View File
@@ -366,6 +366,36 @@ describe('system-routes', () => {
expect(body.success).toBe(false); 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 () => { it('rejects non-object body', async () => {
const res = await harness.app.inject({ const res = await harness.app.inject({
method: 'PUT', method: 'PUT',