chore: project setup and tooling cleanup (#26)

* add project setup

* add tools to eslint

* remove contributing.md

* update claude md

* chore: simplify CI to single Node.js version (22)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* use node 20

* adjust formatting

* fix linting

* format

* fix layout

* fix: align CI node version to .nvmrc (22), remove redundant gotcha

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: arkon <arkon.85@hotmail.com>
This commit is contained in:
Noah Waldner
2026-02-27 13:16:23 +01:00
committed by GitHub
co-authored by Claude Sonnet 4.6 arkon
parent 2b2829ebde
commit 480584de63
61 changed files with 4306 additions and 4013 deletions
+12
View File
@@ -0,0 +1,12 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
+36
View File
@@ -0,0 +1,36 @@
name: CI
on:
push:
branches: [master, main]
pull_request:
jobs:
ci:
name: Typecheck & Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Type check
run: npm run typecheck
- name: Lint
run: npm run lint
- name: Format check
run: npm run format:check
# Note: The test suite is intentionally excluded from CI.
# Tests spawn real tmux sessions and require a full system environment.
# Run tests locally with: npx vitest run test/<file>.test.ts
+8
View File
@@ -41,6 +41,14 @@ Thumbs.db
*.tmp *.tmp
*.temp *.temp
# Generated output
out/
screenshots-echo-diag/
tools/remotion/out/
# Claude Code plan tracking
plan.json
# Unfinished TUI (local development only) # Unfinished TUI (local development only)
src/tui/ src/tui/
.claude/ .claude/
+1
View File
@@ -0,0 +1 @@
22
+9
View File
@@ -0,0 +1,9 @@
dist/
coverage/
node_modules/
src/web/public/vendor/
src/web/public/app.js
src/web/public/styles.css
src/web/public/mobile.css
src/web/public/index.html
tools/
+8
View File
@@ -0,0 +1,8 @@
{
"singleQuote": true,
"semi": true,
"tabWidth": 2,
"printWidth": 120,
"trailingComma": "es5",
"endOfLine": "lf"
}
+1 -2
View File
@@ -64,7 +64,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
## Commands ## Commands
**CRITICAL**: `npm run dev` shows CLI help, NOT the web server. **Note**: `npm run dev` starts the web server (equivalent to `npx tsx src/index.ts web`).
**Default port**: `3000` (web UI at `http://localhost:3000`) **Default port**: `3000` (web UI at `http://localhost:3000`)
@@ -92,7 +92,6 @@ journalctl --user -u codeman-web -f
## Common Gotchas ## Common Gotchas
- **`npm run dev` is NOT the web server** — it shows CLI help. Use `npx tsx src/index.ts web`
- **Single-line prompts only** — `writeViaMux()` sends text and Enter separately; multi-line breaks Ink - **Single-line prompts only** — `writeViaMux()` sends text and Enter separately; multi-line breaks Ink
- **Don't kill tmux sessions blindly** — Check `$CODEMAN_TMUX` first; you might be inside one - **Don't kill tmux sessions blindly** — Check `$CODEMAN_TMUX` first; you might be inside one
- **Never run full test suite** — `npx vitest run` spawns/kills tmux sessions and will crash your Codeman session. Run individual test files only. - **Never run full test suite** — `npx vitest run` spawns/kills tmux sessions and will crash your Codeman session. Run individual test files only.
+29
View File
@@ -0,0 +1,29 @@
// @ts-check
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
eslint.configs.recommended,
tseslint.configs.recommended,
{
rules: {
'no-console': 'off',
'no-debugger': 'error',
// Relax some rules that conflict with existing patterns
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': 'off', // TypeScript compiler already handles this
},
},
{
ignores: [
'dist/**',
'node_modules/**',
'coverage/**',
'src/web/public/vendor/**',
'src/web/public/app.js',
'scripts/**/*.mjs',
'tools/**',
'remotion/**',
],
}
);
+1927 -2604
View File
File diff suppressed because it is too large Load Diff
+16 -5
View File
@@ -10,20 +10,27 @@
}, },
"scripts": { "scripts": {
"postinstall": "node scripts/postinstall.js", "postinstall": "node scripts/postinstall.js",
"build": "tsc && chmod +x dist/index.js && mkdir -p dist/web dist/templates dist/web/public/vendor && cp -r src/web/public dist/web/ && cp src/templates/case-template.md dist/templates/ && cp node_modules/xterm/css/xterm.css dist/web/public/vendor/ && npx esbuild node_modules/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js && npx esbuild node_modules/xterm-addon-fit/lib/xterm-addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js && cp node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js && npx esbuild node_modules/xterm-addon-unicode11/lib/xterm-addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js && npx esbuild dist/web/public/app.js --minify --drop:console --outfile=dist/web/public/app.js --allow-overwrite && npx esbuild dist/web/public/styles.css --minify --outfile=dist/web/public/styles.css --allow-overwrite && npx esbuild dist/web/public/mobile.css --minify --outfile=dist/web/public/mobile.css --allow-overwrite && for f in dist/web/public/*.js dist/web/public/*.css dist/web/public/*.html dist/web/public/vendor/*.js dist/web/public/vendor/*.css; do [ -f \"$f\" ] && gzip -9 -k -f \"$f\" && { brotli -9 -k -f \"$f\" 2>/dev/null || true; }; done", "build": "node scripts/build.mjs",
"start": "node dist/index.js", "start": "node dist/index.js",
"dev": "tsx src/index.ts", "dev": "tsx src/index.ts web",
"web": "node dist/index.js web", "web": "node dist/index.js web",
"clean": "rm -rf dist", "clean": "rm -rf dist",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint 'src/**/*.ts'",
"lint:fix": "eslint 'src/**/*.ts' --fix",
"format": "prettier --write 'src/**/*.ts'",
"format:check": "prettier --check 'src/**/*.ts'",
"capture:subagents": "node scripts/capture-subagent-screenshots.mjs", "capture:subagents": "node scripts/capture-subagent-screenshots.mjs",
"changeset": "changeset", "changeset": "changeset",
"version-packages": "changeset version", "version-packages": "changeset version",
"release": "changeset publish" "release": "changeset publish"
}, },
"workspaces": [
"packages/*"
],
"keywords": [ "keywords": [
"claude", "claude",
"claude-code", "claude-code",
@@ -43,15 +50,12 @@
"@fastify/compress": "^8.3.1", "@fastify/compress": "^8.3.1",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/static": "^8.0.0", "@fastify/static": "^8.0.0",
"@remotion/cli": "4.0.429",
"@remotion/transitions": "4.0.429",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"chokidar": "^3.6.0", "chokidar": "^3.6.0",
"commander": "^12.1.0", "commander": "^12.1.0",
"fastify": "^5.1.0", "fastify": "^5.1.0",
"node-pty": "^1.1.0", "node-pty": "^1.1.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"remotion": "4.0.429",
"uuid": "^10.0.0", "uuid": "^10.0.0",
"web-push": "^3.6.7", "web-push": "^3.6.7",
"xterm": "^5.3.0", "xterm": "^5.3.0",
@@ -62,6 +66,9 @@
}, },
"devDependencies": { "devDependencies": {
"@changesets/cli": "^2.29.8", "@changesets/cli": "^2.29.8",
"@eslint/js": "^9.0.0",
"@remotion/cli": "4.0.429",
"@remotion/transitions": "4.0.429",
"@types/node": "^20.19.33", "@types/node": "^20.19.33",
"@types/pngjs": "^6.0.5", "@types/pngjs": "^6.0.5",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
@@ -70,12 +77,16 @@
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"agent-browser": "^0.6.0", "agent-browser": "^0.6.0",
"esbuild": "^0.27.3", "esbuild": "^0.27.3",
"eslint": "^9.0.0",
"pixelmatch": "^6.0.0", "pixelmatch": "^6.0.0",
"playwright": "^1.58.0", "playwright": "^1.58.0",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
"prettier": "^3.4.0",
"puppeteer": "^24.36.0", "puppeteer": "^24.36.0",
"remotion": "4.0.429",
"tsx": "^4.15.0", "tsx": "^4.15.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.0.0",
"vitest": "^4.0.18" "vitest": "^4.0.18"
}, },
"engines": { "engines": {
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env node
/**
* Build script for Codeman.
* Extracted from the package.json one-liner for readability and debuggability.
*
* Steps:
* 1. TypeScript compilation
* 2. Copy static assets (web/public, templates)
* 3. Build vendor xterm bundles
* 4. Minify frontend assets (app.js, styles.css, mobile.css)
* 5. Compress with gzip + brotli
*/
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
import { join } from 'path';
const ROOT = join(fileURLToPath(import.meta.url), '..', '..');
function run(label, cmd) {
console.log(`\n[build] ${label}`);
execSync(cmd, { stdio: 'inherit', cwd: ROOT, shell: true });
}
// 1. TypeScript compilation
run('tsc', 'tsc');
run('chmod dist/index.js', 'chmod +x dist/index.js');
// 2. Copy static assets
run('prepare dirs', 'mkdir -p dist/web dist/templates dist/web/public/vendor');
run('copy web assets', 'cp -r src/web/public dist/web/');
run('copy template', 'cp src/templates/case-template.md dist/templates/');
// 3. Vendor xterm bundles
run('xterm css', 'cp node_modules/xterm/css/xterm.css dist/web/public/vendor/');
run('xterm js', 'npx esbuild node_modules/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js');
run('xterm-addon-fit', 'npx esbuild node_modules/xterm-addon-fit/lib/xterm-addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js');
run('xterm-addon-webgl', 'cp node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
run('xterm-addon-unicode11', 'npx esbuild node_modules/xterm-addon-unicode11/lib/xterm-addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
// 4. Minify frontend assets
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --drop:console --outfile=dist/web/public/app.js --allow-overwrite');
run('minify styles.css', 'npx esbuild dist/web/public/styles.css --minify --outfile=dist/web/public/styles.css --allow-overwrite');
run('minify mobile.css', 'npx esbuild dist/web/public/mobile.css --minify --outfile=dist/web/public/mobile.css --allow-overwrite');
// 5. Compress with gzip + brotli
run(
'compress',
`for f in dist/web/public/*.js dist/web/public/*.css dist/web/public/*.html dist/web/public/vendor/*.js dist/web/public/vendor/*.css; do` +
` [ -f "$f" ] && gzip -9 -k -f "$f" && { brotli -9 -k -f "$f" 2>/dev/null || true; }; done`
);
console.log('\n✓ Build complete');
+12 -16
View File
@@ -115,7 +115,7 @@ export abstract class AiCheckerBase<
V extends string, V extends string,
C extends AiCheckerConfigBase, C extends AiCheckerConfigBase,
R extends AiCheckerResultBase<V>, R extends AiCheckerResultBase<V>,
S extends AiCheckerStateBase<V> S extends AiCheckerStateBase<V>,
> extends EventEmitter { > extends EventEmitter {
protected config: C; protected config: C;
protected sessionId: string; protected sessionId: string;
@@ -205,9 +205,7 @@ export abstract class AiCheckerBase<
super(); super();
this.sessionId = sessionId; this.sessionId = sessionId;
// Filter out undefined values to prevent overwriting defaults // Filter out undefined values to prevent overwriting defaults
const filteredConfig = Object.fromEntries( const filteredConfig = Object.fromEntries(Object.entries(config).filter(([, v]) => v !== undefined)) as Partial<C>;
Object.entries(config).filter(([, v]) => v !== undefined)
) as Partial<C>;
this.config = { ...defaultConfig, ...filteredConfig }; this.config = { ...defaultConfig, ...filteredConfig };
} }
@@ -342,9 +340,7 @@ export abstract class AiCheckerBase<
/** Update configuration at runtime */ /** Update configuration at runtime */
updateConfig(config: Partial<C>): void { updateConfig(config: Partial<C>): void {
// Filter out undefined values to prevent overwriting existing config // Filter out undefined values to prevent overwriting existing config
const filteredConfig = Object.fromEntries( const filteredConfig = Object.fromEntries(Object.entries(config).filter(([, v]) => v !== undefined)) as Partial<C>;
Object.entries(config).filter(([, v]) => v !== undefined)
) as Partial<C>;
this.config = { ...this.config, ...filteredConfig }; this.config = { ...this.config, ...filteredConfig };
if (config.enabled === false) { if (config.enabled === false) {
this.disable('Disabled by config'); this.disable('Disabled by config');
@@ -369,9 +365,8 @@ export abstract class AiCheckerBase<
// Prepare the terminal buffer (strip ANSI, trim to maxContextChars) // Prepare the terminal buffer (strip ANSI, trim to maxContextChars)
const stripped = terminalBuffer.replace(ANSI_ESCAPE_PATTERN_SIMPLE, ''); const stripped = terminalBuffer.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '');
const trimmed = stripped.length > this.config.maxContextChars const trimmed =
? stripped.slice(-this.config.maxContextChars) stripped.length > this.config.maxContextChars ? stripped.slice(-this.config.maxContextChars) : stripped;
: stripped;
// Build the prompt // Build the prompt
const prompt = this.buildPrompt(trimmed); const prompt = this.buildPrompt(trimmed);
@@ -411,16 +406,15 @@ export abstract class AiCheckerBase<
// No existing session, that's fine // No existing session, that's fine
} }
const muxProcess = childSpawn('tmux', [ const muxProcess = childSpawn('tmux', ['new-session', '-d', '-s', this.checkMuxName, 'bash', '-c', fullCmd], {
'new-session', '-d', '-s', this.checkMuxName,
'bash', '-c', fullCmd
], {
detached: true, detached: true,
stdio: 'ignore', stdio: 'ignore',
}); });
muxProcess.unref(); muxProcess.unref();
} catch (err) { } catch (err) {
throw new Error(`Failed to spawn ${this.checkDescription} tmux session: ${err instanceof Error ? err.message : String(err)}`); throw new Error(
`Failed to spawn ${this.checkDescription} tmux session: ${err instanceof Error ? err.message : String(err)}`
);
} }
// Poll the temp file for completion // Poll the temp file for completion
@@ -530,7 +524,9 @@ export abstract class AiCheckerBase<
private handleError(errorMsg: string): void { private handleError(errorMsg: string): void {
this.consecutiveErrors++; this.consecutiveErrors++;
this.log(`${this.checkDescription} error (${this.consecutiveErrors}/${this.config.maxConsecutiveErrors}): ${errorMsg}`); this.log(
`${this.checkDescription} error (${this.consecutiveErrors}/${this.config.maxConsecutiveErrors}): ${errorMsg}`
);
if (this.consecutiveErrors >= this.config.maxConsecutiveErrors) { if (this.consecutiveErrors >= this.config.maxConsecutiveErrors) {
this.disable(`${this.config.maxConsecutiveErrors} consecutive errors: ${errorMsg}`); this.disable(`${this.config.maxConsecutiveErrors} consecutive errors: ${errorMsg}`);
+4 -9
View File
@@ -33,13 +33,13 @@ import {
// ========== Types ========== // ========== Types ==========
export interface AiIdleCheckConfig extends AiCheckerConfigBase {} export type AiIdleCheckConfig = AiCheckerConfigBase;
export type AiCheckVerdict = 'IDLE' | 'WORKING' | 'ERROR'; export type AiCheckVerdict = 'IDLE' | 'WORKING' | 'ERROR';
export interface AiCheckResult extends AiCheckerResultBase<AiCheckVerdict> {} export type AiCheckResult = AiCheckerResultBase<AiCheckVerdict>;
export interface AiCheckState extends AiCheckerStateBase<AiCheckVerdict> {} export type AiCheckState = AiCheckerStateBase<AiCheckVerdict>;
// ========== Constants ========== // ========== Constants ==========
@@ -123,12 +123,7 @@ Remember: When uncertain, answer WORKING.`;
* Manages AI-powered idle detection by spawning a fresh Claude CLI session * Manages AI-powered idle detection by spawning a fresh Claude CLI session
* to analyze terminal output and provide a definitive IDLE/WORKING verdict. * to analyze terminal output and provide a definitive IDLE/WORKING verdict.
*/ */
export class AiIdleChecker extends AiCheckerBase< export class AiIdleChecker extends AiCheckerBase<AiCheckVerdict, AiIdleCheckConfig, AiCheckResult, AiCheckState> {
AiCheckVerdict,
AiIdleCheckConfig,
AiCheckResult,
AiCheckState
> {
protected readonly muxNamePrefix = 'codeman-aicheck-'; protected readonly muxNamePrefix = 'codeman-aicheck-';
protected readonly doneMarker = '__AICHECK_DONE__'; protected readonly doneMarker = '__AICHECK_DONE__';
protected readonly tempFilePrefix = 'codeman-aicheck'; protected readonly tempFilePrefix = 'codeman-aicheck';
+3 -3
View File
@@ -32,13 +32,13 @@ import {
// ========== Types ========== // ========== Types ==========
export interface AiPlanCheckConfig extends AiCheckerConfigBase {} export type AiPlanCheckConfig = AiCheckerConfigBase;
export type AiPlanCheckVerdict = 'PLAN_MODE' | 'NOT_PLAN_MODE' | 'ERROR'; export type AiPlanCheckVerdict = 'PLAN_MODE' | 'NOT_PLAN_MODE' | 'ERROR';
export interface AiPlanCheckResult extends AiCheckerResultBase<AiPlanCheckVerdict> {} export type AiPlanCheckResult = AiCheckerResultBase<AiPlanCheckVerdict>;
export interface AiPlanCheckState extends AiCheckerStateBase<AiPlanCheckVerdict> {} export type AiPlanCheckState = AiCheckerStateBase<AiPlanCheckVerdict>;
// ========== Constants ========== // ========== Constants ==========
+14 -17
View File
@@ -73,25 +73,25 @@ const FOLLOW_MODE_PATTERN = /\s-[A-Za-z]*f[A-Za-z]*\s|\s--follow\s/;
* Note: This is a simpler approach - we run it on each command string * Note: This is a simpler approach - we run it on each command string
* rather than trying to match globally. * rather than trying to match globally.
*/ */
const FILE_PATH_PATTERN = /(?:^|\s|['"]|=)([\/~][^\s'"<>|;&\n]+)/g; const FILE_PATH_PATTERN = /(?:^|\s|['"]|=)([/~][^\s'"<>|;&\n]+)/g;
/** /**
* Pattern to detect paths that are likely not real files (flags, etc.) * Pattern to detect paths that are likely not real files (flags, etc.)
*/ */
const INVALID_PATH_PATTERN = /^[\/~]-|\/dev\/null$/; const INVALID_PATH_PATTERN = /^[/~]-|\/dev\/null$/;
/** /**
* Pattern to detect command suggestions in plain text output. * Pattern to detect command suggestions in plain text output.
* Matches lines like "tail -f /path/to/file" without the ● Bash() wrapper. * Matches lines like "tail -f /path/to/file" without the ● Bash() wrapper.
* This catches commands Claude mentions but doesn't execute. * This catches commands Claude mentions but doesn't execute.
*/ */
const TEXT_COMMAND_PATTERN = /^\s*(tail|cat|head|less|grep|watch|multitail)\s+(?:-[^\s]+\s+)*([\/~][^\s'"<>|;&\n]+)/; const TEXT_COMMAND_PATTERN = /^\s*(tail|cat|head|less|grep|watch|multitail)\s+(?:-[^\s]+\s+)*([/~][^\s'"<>|;&\n]+)/;
/** /**
* Pattern to detect log file paths mentioned in text (even without commands). * Pattern to detect log file paths mentioned in text (even without commands).
* Matches paths ending in .log, .txt, .out, or in common log directories. * Matches paths ending in .log, .txt, .out, or in common log directories.
*/ */
const LOG_FILE_MENTION_PATTERN = /([\/~][^\s'"<>|;&\n]*(?:\.log|\.txt|\.out|\/log\/[^\s'"<>|;&\n]+))/g; const LOG_FILE_MENTION_PATTERN = /([/~][^\s'"<>|;&\n]*(?:\.log|\.txt|\.out|\/log\/[^\s'"<>|;&\n]+))/g;
// ========== Event Interfaces ========== // ========== Event Interfaces ==========
@@ -245,7 +245,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
*/ */
private isShallowRootPath(path: string): boolean { private isShallowRootPath(path: string): boolean {
if (!path.startsWith('/')) return false; if (!path.startsWith('/')) return false;
const parts = path.split('/').filter(p => p !== ''); const parts = path.split('/').filter((p) => p !== '');
return parts.length === 1; return parts.length === 1;
} }
@@ -354,10 +354,10 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
isFilePathTracked(filePath: string): boolean { isFilePathTracked(filePath: string): boolean {
const normalizedNew = this.normalizePath(filePath); const normalizedNew = this.normalizePath(filePath);
return Array.from(this._activeTools.values()).some(t => { return Array.from(this._activeTools.values()).some((t) => {
if (t.status !== 'running') return false; if (t.status !== 'running') return false;
return t.filePaths.some(existingPath => { return t.filePaths.some((existingPath) => {
const normalizedExisting = this.normalizePath(existingPath); const normalizedExisting = this.normalizePath(existingPath);
return normalizedExisting === normalizedNew; return normalizedExisting === normalizedNew;
}); });
@@ -418,9 +418,8 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
// Prevent unbounded growth // Prevent unbounded growth
if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) { if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) {
const trimPoint = this._lineBuffer.lastIndexOf('\n', MAX_LINE_BUFFER_SIZE / 2); const trimPoint = this._lineBuffer.lastIndexOf('\n', MAX_LINE_BUFFER_SIZE / 2);
this._lineBuffer = trimPoint > 0 this._lineBuffer =
? this._lineBuffer.slice(trimPoint + 1) trimPoint > 0 ? this._lineBuffer.slice(trimPoint + 1) : this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
: this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
} }
// Process complete lines // Process complete lines
@@ -445,9 +444,8 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) { if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) {
const trimPoint = this._lineBuffer.lastIndexOf('\n', MAX_LINE_BUFFER_SIZE / 2); const trimPoint = this._lineBuffer.lastIndexOf('\n', MAX_LINE_BUFFER_SIZE / 2);
this._lineBuffer = trimPoint > 0 this._lineBuffer =
? this._lineBuffer.slice(trimPoint + 1) trimPoint > 0 ? this._lineBuffer.slice(trimPoint + 1) : this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
: this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
} }
const lines = this._lineBuffer.split('\n'); const lines = this._lineBuffer.split('\n');
@@ -472,7 +470,6 @@ 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 // Check for tool start
const startMatch = cleanLine.match(BASH_TOOL_START_PATTERN); const startMatch = cleanLine.match(BASH_TOOL_START_PATTERN);
if (startMatch) { if (startMatch) {
@@ -484,7 +481,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
const filePaths = this.extractFilePaths(command); const filePaths = this.extractFilePaths(command);
// Skip if any file path is already tracked (cross-pattern dedup) // Skip if any file path is already tracked (cross-pattern dedup)
if (filePaths.some(fp => this.isFilePathTracked(fp))) { if (filePaths.some((fp) => this.isFilePathTracked(fp))) {
return; return;
} }
@@ -502,8 +499,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
// Enforce max tools limit // Enforce max tools limit
if (this._activeTools.size >= MAX_ACTIVE_TOOLS) { if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
// Remove oldest tool // Remove oldest tool
const oldest = Array.from(this._activeTools.entries()) const oldest = Array.from(this._activeTools.entries()).sort((a, b) => a[1].startedAt - b[1].startedAt)[0];
.sort((a, b) => a[1].startedAt - b[1].startedAt)[0];
if (oldest) { if (oldest) {
this._activeTools.delete(oldest[0]); this._activeTools.delete(oldest[0]);
} }
@@ -671,6 +667,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
*/ */
private stripAnsi(str: string): string { private stripAnsi(str: string): string {
// Comprehensive ANSI pattern // Comprehensive ANSI pattern
// eslint-disable-next-line no-control-regex
return str.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g, ''); return str.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g, '');
} }
+31 -44
View File
@@ -21,17 +21,11 @@ const pkg = require('../package.json') as { version: string };
const program = new Command(); const program = new Command();
program program.name('codeman').description('Claude Code session manager with autonomous Ralph Loop').version(pkg.version);
.name('codeman')
.description('Claude Code session manager with autonomous Ralph Loop')
.version(pkg.version);
// ============ Session Commands ============ // ============ Session Commands ============
const sessionCmd = program const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
.command('session')
.alias('s')
.description('Manage Claude sessions');
sessionCmd sessionCmd
.command('start') .command('start')
@@ -83,11 +77,12 @@ sessionCmd
console.log(' (none)'); console.log(' (none)');
} else { } else {
for (const session of sessions) { for (const session of sessions) {
const status = session.status === 'idle' const status =
? chalk.green('idle') session.status === 'idle'
: session.status === 'busy' ? chalk.green('idle')
? chalk.yellow('busy') : session.status === 'busy'
: chalk.red(session.status); ? chalk.yellow('busy')
: chalk.red(session.status);
console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status} ${session.workingDir}`); console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status} ${session.workingDir}`);
} }
} }
@@ -106,11 +101,12 @@ sessionCmd
if (sessions.length === 0 && activeSessions.length > 0) { if (sessions.length === 0 && activeSessions.length > 0) {
console.log(chalk.bold('\nActive Sessions (from web server):')); console.log(chalk.bold('\nActive Sessions (from web server):'));
for (const session of activeSessions) { for (const session of activeSessions) {
const status = session.status === 'idle' const status =
? chalk.green('idle') session.status === 'idle'
: session.status === 'busy' ? chalk.green('idle')
? chalk.yellow('busy') : session.status === 'busy'
: chalk.red(session.status); ? chalk.yellow('busy')
: chalk.red(session.status);
const name = session.name ? ` (${session.name})` : ''; const name = session.name ? ` (${session.name})` : '';
const mode = session.mode === 'shell' ? chalk.gray(' [shell]') : ''; const mode = session.mode === 'shell' ? chalk.gray(' [shell]') : '';
const cost = session.totalCost ? chalk.gray(` $${session.totalCost.toFixed(4)}`) : ''; const cost = session.totalCost ? chalk.gray(` $${session.totalCost.toFixed(4)}`) : '';
@@ -126,9 +122,7 @@ sessionCmd
.option('-e, --errors', 'Show stderr instead of stdout') .option('-e, --errors', 'Show stderr instead of stdout')
.action((id, options) => { .action((id, options) => {
const manager = getSessionManager(); const manager = getSessionManager();
const output = options.errors const output = options.errors ? manager.getSessionError(id) : manager.getSessionOutput(id);
? manager.getSessionError(id)
: manager.getSessionOutput(id);
if (output === null) { if (output === null) {
console.log(chalk.yellow(`Session ${id} not found or not active`)); console.log(chalk.yellow(`Session ${id} not found or not active`));
@@ -145,10 +139,7 @@ sessionCmd
// ============ Task Commands ============ // ============ Task Commands ============
const taskCmd = program const taskCmd = program.command('task').alias('t').description('Manage tasks');
.command('task')
.alias('t')
.description('Manage tasks');
taskCmd taskCmd
.command('add <prompt>') .command('add <prompt>')
@@ -205,7 +196,9 @@ taskCmd
const counts = queue.getCount(); const counts = queue.getCount();
console.log(chalk.bold('\nSummary:')); console.log(chalk.bold('\nSummary:'));
console.log(` Pending: ${counts.pending}, Running: ${counts.running}, Completed: ${counts.completed}, Failed: ${counts.failed}`); console.log(
` Pending: ${counts.pending}, Running: ${counts.running}, Completed: ${counts.completed}, Failed: ${counts.failed}`
);
console.log(''); console.log('');
}); });
@@ -276,10 +269,7 @@ taskCmd
// ============ Ralph Loop Commands ============ // ============ Ralph Loop Commands ============
const ralphCmd = program const ralphCmd = program.command('ralph').alias('r').description('Control the Ralph autonomous loop');
.command('ralph')
.alias('r')
.description('Control the Ralph autonomous loop');
ralphCmd ralphCmd
.command('start') .command('start')
@@ -355,17 +345,16 @@ ralphCmd
}); });
function printStats(stats: ReturnType<ReturnType<typeof getRalphLoop>['getStats']>) { function printStats(stats: ReturnType<ReturnType<typeof getRalphLoop>['getStats']>) {
const statusColor = const statusColor = stats.status === 'running' ? chalk.green : stats.status === 'paused' ? chalk.yellow : chalk.gray;
stats.status === 'running' ? chalk.green :
stats.status === 'paused' ? chalk.yellow :
chalk.gray;
console.log(chalk.bold('\nRalph Loop Status:')); console.log(chalk.bold('\nRalph Loop Status:'));
console.log(` Status: ${statusColor(stats.status)}`); console.log(` Status: ${statusColor(stats.status)}`);
console.log(` Elapsed: ${stats.elapsedHours.toFixed(2)} hours`); console.log(` Elapsed: ${stats.elapsedHours.toFixed(2)} hours`);
if (stats.minDurationMs) { if (stats.minDurationMs) {
const minHours = stats.minDurationMs / (1000 * 60 * 60); const minHours = stats.minDurationMs / (1000 * 60 * 60);
console.log(` Min Duration: ${minHours.toFixed(2)} hours (${stats.minDurationReached ? 'reached' : 'not reached'})`); console.log(
` Min Duration: ${minHours.toFixed(2)} hours (${stats.minDurationReached ? 'reached' : 'not reached'})`
);
} }
console.log(chalk.bold('\nTasks:')); console.log(chalk.bold('\nTasks:'));
@@ -422,10 +411,7 @@ program
console.log(` Completed: ${taskCounts.completed}`); console.log(` Completed: ${taskCounts.completed}`);
console.log(` Failed: ${taskCounts.failed}`); console.log(` Failed: ${taskCounts.failed}`);
const statusColor = const statusColor = loopStatus === 'running' ? chalk.green : loopStatus === 'paused' ? chalk.yellow : chalk.gray;
loopStatus === 'running' ? chalk.green :
loopStatus === 'paused' ? chalk.yellow :
chalk.gray;
console.log(chalk.bold('\nRalph Loop:')); console.log(chalk.bold('\nRalph Loop:'));
console.log(` Status: ${statusColor(loopStatus)}`); console.log(` Status: ${statusColor(loopStatus)}`);
console.log(''); console.log('');
@@ -481,11 +467,12 @@ program
console.log(' (none)'); console.log(' (none)');
} else { } else {
for (const session of sessions) { for (const session of sessions) {
const status = session.status === 'idle' const status =
? chalk.green('idle') session.status === 'idle'
: session.status === 'busy' ? chalk.green('idle')
? chalk.yellow('busy') : session.status === 'busy'
: chalk.red(session.status); ? chalk.yellow('busy')
: chalk.red(session.status);
console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status} ${session.workingDir}`); console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status} ${session.workingDir}`);
} }
} }
+5 -4
View File
@@ -171,7 +171,10 @@ 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(`[FileStreamManager] Failed to stat file "${absolutePath}" (${errorCode}):`, err instanceof Error ? err.message : String(err)); console.warn(
`[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' };
} }
@@ -379,9 +382,7 @@ export class FileStreamManager extends EventEmitter {
} }
// Resolve to absolute path // Resolve to absolute path
let absolutePath = isAbsolute(expandedPath) let absolutePath = isAbsolute(expandedPath) ? resolve(expandedPath) : resolve(workingDir, expandedPath);
? resolve(expandedPath)
: resolve(workingDir, expandedPath);
// Resolve symlinks to prevent symlink attacks — validate the real target, // Resolve symlinks to prevent symlink attacks — validate the real target,
// not the symlink itself. Fall back to resolved path if file doesn't exist yet. // not the symlink itself. Fall back to resolved path if file doesn't exist yet.
+7 -10
View File
@@ -24,15 +24,7 @@ export interface ImageWatcherEvents {
// ========== Constants ========== // ========== Constants ==========
/** Supported image file extensions (lowercase) */ /** Supported image file extensions (lowercase) */
const IMAGE_EXTENSIONS = new Set([ const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.bmp',
'.svg',
]);
/** Time to wait for file writes to stabilize (ms) */ /** Time to wait for file writes to stabilize (ms) */
const STABILITY_THRESHOLD_MS = 500; const STABILITY_THRESHOLD_MS = 500;
@@ -171,7 +163,12 @@ export class ImageWatcher extends EventEmitter {
// Ignore common heavy directories for performance // Ignore common heavy directories for performance
ignored: (path: string) => { ignored: (path: string) => {
// Skip node_modules, .git, and other heavy directories // Skip node_modules, .git, and other heavy directories
if (path.includes('/node_modules/') || path.includes('/.git/') || path.includes('/dist/') || path.includes('/.next/')) { if (
path.includes('/node_modules/') ||
path.includes('/.git/') ||
path.includes('/dist/') ||
path.includes('/.next/')
) {
return true; return true;
} }
const ext = extname(path).toLowerCase(); const ext = extname(path).toLowerCase();
+3 -1
View File
@@ -23,7 +23,9 @@ let errorResetTimer: ReturnType<typeof setTimeout> | null = null;
function trackError(): void { function trackError(): void {
consecutiveErrors++; consecutiveErrors++;
if (errorResetTimer) clearTimeout(errorResetTimer); if (errorResetTimer) clearTimeout(errorResetTimer);
errorResetTimer = setTimeout(() => { consecutiveErrors = 0; }, ERROR_RESET_MS); errorResetTimer = setTimeout(() => {
consecutiveErrors = 0;
}, ERROR_RESET_MS);
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
console.error(`[FATAL] ${MAX_CONSECUTIVE_ERRORS} consecutive unhandled errors — exiting for systemd restart`); console.error(`[FATAL] ${MAX_CONSECUTIVE_ERRORS} consecutive unhandled errors — exiting for systemd restart`);
+8 -1
View File
@@ -7,7 +7,14 @@
*/ */
import type { EventEmitter } from 'node:events'; import type { EventEmitter } from 'node:events';
import type { ProcessStats, PersistedRespawnConfig, NiceConfig, ClaudeMode, SessionMode, OpenCodeConfig } from './types.js'; import type {
ProcessStats,
PersistedRespawnConfig,
NiceConfig,
ClaudeMode,
SessionMode,
OpenCodeConfig,
} from './types.js';
/** /**
* Multiplexer session metadata. * Multiplexer session metadata.
+178 -30
View File
@@ -19,10 +19,7 @@ import { Session } from './session.js';
import type { TerminalMultiplexer } from './mux-interface.js'; 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 { import { RESEARCH_AGENT_PROMPT, PLANNER_PROMPT } from './prompts/index.js';
RESEARCH_AGENT_PROMPT,
PLANNER_PROMPT,
} from './prompts/index.js';
import { PlanTaskStatus, TddPhase } from './types.js'; import { PlanTaskStatus, TddPhase } from './types.js';
// ============================================================================ // ============================================================================
@@ -170,7 +167,7 @@ export class PlanOrchestrator {
mux: TerminalMultiplexer, mux: TerminalMultiplexer,
workingDir: string = process.cwd(), workingDir: string = process.cwd(),
outputDir?: string, outputDir?: string,
modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record<string, string> }, modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record<string, string> }
) { ) {
this.mux = mux; this.mux = mux;
this.workingDir = workingDir; this.workingDir = workingDir;
@@ -193,7 +190,11 @@ export class PlanOrchestrator {
} }
const promptPath = join(agentDir, 'prompt.md'); const promptPath = join(agentDir, 'prompt.md');
writeFileSync(promptPath, `# ${agentType} Agent Prompt\n\nGenerated: ${new Date().toISOString()}\nDuration: ${(durationMs / 1000).toFixed(1)}s\n\n## Task\n${this.taskDescription}\n\n## Prompt\n${prompt}\n`, 'utf-8'); writeFileSync(
promptPath,
`# ${agentType} Agent Prompt\n\nGenerated: ${new Date().toISOString()}\nDuration: ${(durationMs / 1000).toFixed(1)}s\n\n## Task\n${this.taskDescription}\n\n## Prompt\n${prompt}\n`,
'utf-8'
);
const resultPath = join(agentDir, 'result.json'); const resultPath = join(agentDir, 'result.json');
writeFileSync(resultPath, JSON.stringify(result, null, 2), 'utf-8'); writeFileSync(resultPath, JSON.stringify(result, null, 2), 'utf-8');
@@ -227,9 +228,9 @@ export class PlanOrchestrator {
private generateSummary(result: DetailedPlanResult): string { private generateSummary(result: DetailedPlanResult): string {
const items = result.items || []; const items = result.items || [];
const p0 = items.filter(i => i.priority === 'P0'); const p0 = items.filter((i) => i.priority === 'P0');
const p1 = items.filter(i => i.priority === 'P1'); const p1 = items.filter((i) => i.priority === 'P1');
const p2 = items.filter(i => i.priority === 'P2'); const p2 = items.filter((i) => i.priority === 'P2');
let md = `# Plan Summary\n\n`; let md = `# Plan Summary\n\n`;
md += `Generated: ${new Date().toISOString()}\n`; md += `Generated: ${new Date().toISOString()}\n`;
@@ -392,10 +393,29 @@ export class PlanOrchestrator {
const startTime = Date.now(); const startTime = Date.now();
if (this.cancelled) { if (this.cancelled) {
return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error: 'Cancelled', durationMs: 0 }; return {
success: false,
findings: {
externalResources: [],
codebasePatterns: [],
technicalRecommendations: [],
potentialChallenges: [],
recommendedTools: [],
},
enrichedTaskDescription: taskDescription,
error: 'Cancelled',
durationMs: 0,
};
} }
onSubagent?.({ type: 'started', agentId, agentType: 'research', model: this.researchModel, status: 'running', detail: 'Researching...' }); onSubagent?.({
type: 'started',
agentId,
agentType: 'research',
model: this.researchModel,
status: 'running',
detail: 'Researching...',
});
const session = new Session({ const session = new Session({
workingDir: this.workingDir, workingDir: this.workingDir,
@@ -411,7 +431,14 @@ export class PlanOrchestrator {
// Start progress interval before try block to ensure cleanup in finally // Start progress interval before try block to ensure cleanup in finally
const progressInterval = setInterval(() => { const progressInterval = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000); const elapsed = Math.floor((Date.now() - startTime) / 1000);
onSubagent?.({ type: 'progress', agentId, agentType: 'research', model: this.researchModel, status: 'running', detail: `${elapsed}s elapsed` }); onSubagent?.({
type: 'progress',
agentId,
agentType: 'research',
model: this.researchModel,
status: 'running',
detail: `${elapsed}s elapsed`,
});
}, 30000); }, 30000);
try { try {
@@ -424,14 +451,54 @@ export class PlanOrchestrator {
// Extract JSON from response // Extract JSON from response
const jsonMatch = response.match(/\{[\s\S]*\}/); const jsonMatch = response.match(/\{[\s\S]*\}/);
if (!jsonMatch) { if (!jsonMatch) {
onSubagent?.({ type: 'failed', agentId, agentType: 'research', model: this.researchModel, status: 'failed', error: 'No JSON found', durationMs }); onSubagent?.({
return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error: 'No JSON in response', durationMs }; type: 'failed',
agentId,
agentType: 'research',
model: this.researchModel,
status: 'failed',
error: 'No JSON found',
durationMs,
});
return {
success: false,
findings: {
externalResources: [],
codebasePatterns: [],
technicalRecommendations: [],
potentialChallenges: [],
recommendedTools: [],
},
enrichedTaskDescription: taskDescription,
error: 'No JSON in response',
durationMs,
};
} }
const parsed = tryParseJSON(jsonMatch[0]); const parsed = tryParseJSON(jsonMatch[0]);
if (!parsed.success) { if (!parsed.success) {
onSubagent?.({ type: 'failed', agentId, agentType: 'research', model: this.researchModel, status: 'failed', error: parsed.error, durationMs }); onSubagent?.({
return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error: parsed.error, durationMs }; type: 'failed',
agentId,
agentType: 'research',
model: this.researchModel,
status: 'failed',
error: parsed.error,
durationMs,
});
return {
success: false,
findings: {
externalResources: [],
codebasePatterns: [],
technicalRecommendations: [],
potentialChallenges: [],
recommendedTools: [],
},
enrichedTaskDescription: taskDescription,
error: parsed.error,
durationMs,
};
} }
const data = parsed.data as Record<string, unknown>; const data = parsed.data as Record<string, unknown>;
@@ -444,20 +511,48 @@ export class PlanOrchestrator {
potentialChallenges: Array.isArray(data.potentialChallenges) ? data.potentialChallenges : [], potentialChallenges: Array.isArray(data.potentialChallenges) ? data.potentialChallenges : [],
recommendedTools: Array.isArray(data.recommendedTools) ? data.recommendedTools : [], recommendedTools: Array.isArray(data.recommendedTools) ? data.recommendedTools : [],
}, },
enrichedTaskDescription: typeof data.enrichedTaskDescription === 'string' ? data.enrichedTaskDescription : taskDescription, enrichedTaskDescription:
typeof data.enrichedTaskDescription === 'string' ? data.enrichedTaskDescription : taskDescription,
durationMs, durationMs,
}; };
this.saveAgentOutput('research', prompt, result, durationMs); this.saveAgentOutput('research', prompt, result, durationMs);
onSubagent?.({ type: 'completed', agentId, agentType: 'research', model: this.researchModel, status: 'completed', durationMs }); onSubagent?.({
type: 'completed',
agentId,
agentType: 'research',
model: this.researchModel,
status: 'completed',
durationMs,
});
return result; return result;
} catch (err) { } catch (err) {
this.runningSessions.delete(session); this.runningSessions.delete(session);
const durationMs = Date.now() - startTime; const durationMs = Date.now() - startTime;
const error = err instanceof Error ? err.message : String(err); const error = err instanceof Error ? err.message : String(err);
onSubagent?.({ type: 'failed', agentId, agentType: 'research', model: this.researchModel, status: 'failed', error, durationMs }); onSubagent?.({
return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error, durationMs }; type: 'failed',
agentId,
agentType: 'research',
model: this.researchModel,
status: 'failed',
error,
durationMs,
});
return {
success: false,
findings: {
externalResources: [],
codebasePatterns: [],
technicalRecommendations: [],
potentialChallenges: [],
recommendedTools: [],
},
enrichedTaskDescription: taskDescription,
error,
durationMs,
};
} finally { } finally {
// Always clear the progress interval to prevent memory leaks // Always clear the progress interval to prevent memory leaks
clearInterval(progressInterval); clearInterval(progressInterval);
@@ -469,7 +564,13 @@ export class PlanOrchestrator {
researchContext: string, researchContext: string,
onProgress?: ProgressCallback, onProgress?: ProgressCallback,
onSubagent?: SubagentCallback onSubagent?: SubagentCallback
): Promise<{ success: boolean; items?: PlanItem[]; gaps?: string[]; warnings?: string[]; error?: string }> { ): Promise<{
success: boolean;
items?: PlanItem[];
gaps?: string[];
warnings?: string[];
error?: string;
}> {
const agentId = `planner-${Date.now()}`; const agentId = `planner-${Date.now()}`;
const startTime = Date.now(); const startTime = Date.now();
@@ -477,7 +578,14 @@ export class PlanOrchestrator {
return { success: false, error: 'Cancelled' }; return { success: false, error: 'Cancelled' };
} }
onSubagent?.({ type: 'started', agentId, agentType: 'planner', model: this.plannerModel, status: 'running', detail: 'Generating plan...' }); onSubagent?.({
type: 'started',
agentId,
agentType: 'planner',
model: this.plannerModel,
status: 'running',
detail: 'Generating plan...',
});
const session = new Session({ const session = new Session({
workingDir: this.workingDir, workingDir: this.workingDir,
@@ -488,14 +596,22 @@ export class PlanOrchestrator {
this.runningSessions.add(session); this.runningSessions.add(session);
const prompt = PLANNER_PROMPT const prompt = PLANNER_PROMPT.replace('{TASK}', taskDescription).replace(
.replace('{TASK}', taskDescription) '{RESEARCH_CONTEXT}',
.replace('{RESEARCH_CONTEXT}', researchContext || ''); researchContext || ''
);
// Start progress interval before try block to ensure cleanup in finally // Start progress interval before try block to ensure cleanup in finally
const progressInterval = setInterval(() => { const progressInterval = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000); const elapsed = Math.floor((Date.now() - startTime) / 1000);
onSubagent?.({ type: 'progress', agentId, agentType: 'planner', model: this.plannerModel, status: 'running', detail: `${elapsed}s elapsed` }); onSubagent?.({
type: 'progress',
agentId,
agentType: 'planner',
model: this.plannerModel,
status: 'running',
detail: `${elapsed}s elapsed`,
});
}, 30000); }, 30000);
try { try {
@@ -508,13 +624,29 @@ export class PlanOrchestrator {
// Extract JSON from response // Extract JSON from response
const jsonMatch = response.match(/\{[\s\S]*\}/); const jsonMatch = response.match(/\{[\s\S]*\}/);
if (!jsonMatch) { if (!jsonMatch) {
onSubagent?.({ type: 'failed', agentId, agentType: 'planner', model: this.plannerModel, status: 'failed', error: 'No JSON found', durationMs }); onSubagent?.({
type: 'failed',
agentId,
agentType: 'planner',
model: this.plannerModel,
status: 'failed',
error: '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(jsonMatch[0]);
if (!parsed.success) { if (!parsed.success) {
onSubagent?.({ type: 'failed', agentId, agentType: 'planner', model: this.plannerModel, status: 'failed', error: parsed.error, durationMs }); onSubagent?.({
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 };
} }
@@ -524,7 +656,15 @@ export class PlanOrchestrator {
const warnings: string[] = Array.isArray(data.warnings) ? data.warnings : []; const warnings: string[] = Array.isArray(data.warnings) ? data.warnings : [];
this.saveAgentOutput('planner', prompt, { items, gaps, warnings }, durationMs); this.saveAgentOutput('planner', prompt, { items, gaps, warnings }, durationMs);
onSubagent?.({ type: 'completed', agentId, agentType: 'planner', model: this.plannerModel, status: 'completed', itemCount: items.length, durationMs }); onSubagent?.({
type: 'completed',
agentId,
agentType: 'planner',
model: this.plannerModel,
status: 'completed',
itemCount: items.length,
durationMs,
});
onProgress?.('planning', `Generated ${items.length} tasks`); onProgress?.('planning', `Generated ${items.length} tasks`);
@@ -533,7 +673,15 @@ export class PlanOrchestrator {
this.runningSessions.delete(session); this.runningSessions.delete(session);
const durationMs = Date.now() - startTime; const durationMs = Date.now() - startTime;
const error = err instanceof Error ? err.message : String(err); const error = err instanceof Error ? err.message : String(err);
onSubagent?.({ type: 'failed', agentId, agentType: 'planner', model: this.plannerModel, status: 'failed', error, durationMs }); onSubagent?.({
type: 'failed',
agentId,
agentType: 'planner',
model: this.plannerModel,
status: 'failed',
error,
durationMs,
});
return { success: false, error }; return { success: false, error };
} finally { } finally {
// Always clear the progress interval to prevent memory leaks // Always clear the progress interval to prevent memory leaks
+2 -9
View File
@@ -282,10 +282,7 @@ export class RalphLoop extends EventEmitter {
this.store.setRalphLoopState({ lastCheckAt: Date.now() }); this.store.setRalphLoopState({ lastCheckAt: Date.now() });
// Run independent checks in parallel for better performance // Run independent checks in parallel for better performance
await Promise.all([ await Promise.all([this.checkTimeouts(), this.assignTasks()]);
this.checkTimeouts(),
this.assignTasks(),
]);
// Check if we should auto-generate tasks (depends on assignment results) // Check if we should auto-generate tasks (depends on assignment results)
if (this.autoGenerateTasks && this.shouldGenerateTasks()) { if (this.autoGenerateTasks && this.shouldGenerateTasks()) {
@@ -409,11 +406,7 @@ export class RalphLoop extends EventEmitter {
// 2. Min duration not reached // 2. Min duration not reached
// 3. We have idle sessions // 3. We have idle sessions
const counts = this.taskQueue.getCount(); const counts = this.taskQueue.getCount();
return ( return counts.pending === 0 && !this.isMinDurationReached() && this.sessionManager.getIdleSessions().length > 0;
counts.pending === 0 &&
!this.isMinDurationReached() &&
this.sessionManager.getIdleSessions().length > 0
);
} }
private async generateFollowUpTasks(): Promise<void> { private async generateFollowUpTasks(): Promise<void> {
+179 -154
View File
@@ -34,12 +34,7 @@ import {
PlanTaskStatus, PlanTaskStatus,
TddPhase, TddPhase,
} from './types.js'; } from './types.js';
import { import { ANSI_ESCAPE_PATTERN_SIMPLE, fuzzyPhraseMatch, todoContentHash, stringSimilarity } from './utils/index.js';
ANSI_ESCAPE_PATTERN_SIMPLE,
fuzzyPhraseMatch,
todoContentHash,
stringSimilarity,
} from './utils/index.js';
import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js'; import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js';
import { MAX_TODOS_PER_SESSION } from './config/map-limits.js'; import { MAX_TODOS_PER_SESSION } from './config/map-limits.js';
@@ -151,8 +146,19 @@ const MAX_PLAN_HISTORY = 10;
* P1-002: Configurable false positive prevention * P1-002: Configurable false positive prevention
*/ */
const COMMON_COMPLETION_PHRASES = new Set([ const COMMON_COMPLETION_PHRASES = new Set([
'DONE', 'COMPLETE', 'FINISHED', 'OK', 'YES', 'TRUE', 'SUCCESS', 'DONE',
'READY', 'COMPLETED', 'PASSED', 'END', 'STOP', 'EXIT', 'COMPLETE',
'FINISHED',
'OK',
'YES',
'TRUE',
'SUCCESS',
'READY',
'COMPLETED',
'PASSED',
'END',
'STOP',
'EXIT',
]); ]);
/** /**
@@ -249,9 +255,9 @@ const TODO_PLAIN_CHECKMARK_PATTERN = /✔\s+(.+)/g;
* Prevents false positives from tool invocations and Claude commentary * Prevents false positives from tool invocations and Claude commentary
*/ */
const TODO_EXCLUDE_PATTERNS = [ const TODO_EXCLUDE_PATTERNS = [
/^(?:Bash|Search|Read|Write|Glob|Grep|Edit|Task)\s*\(/i, // Tool invocations /^(?:Bash|Search|Read|Write|Glob|Grep|Edit|Task)\s*\(/i, // Tool invocations
/^(?:I'll |Let me |Now I|First,|Task \d+:|Result:|Error:)/i, // Claude commentary /^(?:I'll |Let me |Now I|First,|Task \d+:|Result:|Error:)/i, // Claude commentary
/^\S+\([^)]+\)$/, // Generic function call pattern /^\S+\([^)]+\)$/, // Generic function call pattern
]; ];
// ---------- Loop Status Patterns ---------- // ---------- Loop Status Patterns ----------
@@ -313,7 +319,8 @@ const TODOWRITE_PATTERN = /TodoWrite|todo(?:s)?\s*(?:updated|written|saved)|Todo
* Examples: "All 8 files have been created", "All tasks completed", "Everything is done" * Examples: "All 8 files have been created", "All tasks completed", "Everything is done"
* Used to mark all tracked todos as complete at once * Used to mark all tracked todos as complete at once
*/ */
const ALL_COMPLETE_PATTERN = /all\s+(?:\d+\s+)?(?:tasks?|files?|items?)\s+(?:have\s+been\s+|are\s+)?(?:completed?|done|finished|created)|completed?\s+all\s+(?:\d+\s+)?tasks?|all\s+done|everything\s+(?:is\s+)?(?:completed?|done)|finished\s+all\s+tasks?/i; const ALL_COMPLETE_PATTERN =
/all\s+(?:\d+\s+)?(?:tasks?|files?|items?)\s+(?:have\s+been\s+|are\s+)?(?:completed?|done|finished|created)|completed?\s+all\s+(?:\d+\s+)?tasks?|all\s+done|everything\s+(?:is\s+)?(?:completed?|done)|finished\s+all\s+tasks?/i;
/** /**
* Extracts count from "all N items" messages * Extracts count from "all N items" messages
@@ -327,7 +334,8 @@ const ALL_COUNT_PATTERN = /all\s+(\d+)\s+(?:tasks?|files?|items?)/i;
* Examples: "Task #5 is done", "marked as completed", "todo 3 finished" * Examples: "Task #5 is done", "marked as completed", "todo 3 finished"
* Used to update specific todo items by number * Used to update specific todo items by number
*/ */
const TASK_DONE_PATTERN = /(?:task|item|todo)\s*(?:#?\d+|"\s*[^"]+\s*")?\s*(?:is\s+)?(?:done|completed?|finished)|(?:completed?|done|finished)\s+(?:task|item)\s*(?:#?\d+)?|marking\s+(?:.*?\s+)?(?:as\s+)?completed?|marked\s+(?:.*?\s+)?(?:as\s+)?completed?/i; const TASK_DONE_PATTERN =
/(?:task|item|todo)\s*(?:#?\d+|"\s*[^"]+\s*")?\s*(?:is\s+)?(?:done|completed?|finished)|(?:completed?|done|finished)\s+(?:task|item)\s*(?:#?\d+)?|marking\s+(?:.*?\s+)?(?:as\s+)?completed?|marked\s+(?:.*?\s+)?(?:as\s+)?completed?/i;
// ---------- Utility Patterns ---------- // ---------- Utility Patterns ----------
@@ -411,48 +419,48 @@ const COMPLETION_INDICATOR_PATTERNS = [
/** P0 (Critical) priority patterns - highest severity issues */ /** P0 (Critical) priority patterns - highest severity issues */
const P0_PRIORITY_PATTERNS = [ const P0_PRIORITY_PATTERNS = [
/\bP0\b|\(P0\)|:?\s*P0\s*:/, // Explicit P0 /\bP0\b|\(P0\)|:?\s*P0\s*:/, // Explicit P0
/\bCRITICAL\b/, // Critical keyword /\bCRITICAL\b/, // Critical keyword
/\bBLOCKER\b/, // Blocker /\bBLOCKER\b/, // Blocker
/\bURGENT\b/, // Urgent /\bURGENT\b/, // Urgent
/\bSECURITY\b/, // Security issues /\bSECURITY\b/, // Security issues
/\bCRASH(?:ES|ING)?\b/, // Crash, crashes, crashing /\bCRASH(?:ES|ING)?\b/, // Crash, crashes, crashing
/\bBROKEN\b/, // Broken /\bBROKEN\b/, // Broken
/\bDATA\s*LOSS\b/, // Data loss /\bDATA\s*LOSS\b/, // Data loss
/\bPRODUCTION\s*(?:DOWN|ISSUE|BUG)\b/, // Production issues /\bPRODUCTION\s*(?:DOWN|ISSUE|BUG)\b/, // Production issues
/\bHOTFIX\b/, // Hotfix /\bHOTFIX\b/, // Hotfix
/\bSEVERITY\s*1\b/, // Severity 1 /\bSEVERITY\s*1\b/, // Severity 1
]; ];
/** P1 (High) priority patterns - important issues requiring attention */ /** P1 (High) priority patterns - important issues requiring attention */
const P1_PRIORITY_PATTERNS = [ const P1_PRIORITY_PATTERNS = [
/\bP1\b|\(P1\)|:?\s*P1\s*:/, // Explicit P1 /\bP1\b|\(P1\)|:?\s*P1\s*:/, // Explicit P1
/\bHIGH\s*PRIORITY\b/, // High priority /\bHIGH\s*PRIORITY\b/, // High priority
/\bIMPORTANT\b/, // Important /\bIMPORTANT\b/, // Important
/\bBUG\b/, // Bug /\bBUG\b/, // Bug
/\bFIX\b/, // Fix (as task type) /\bFIX\b/, // Fix (as task type)
/\bERROR\b/, // Error /\bERROR\b/, // Error
/\bFAIL(?:S|ED|ING|URE)?\b/, // Fail variants /\bFAIL(?:S|ED|ING|URE)?\b/, // Fail variants
/\bREGRESSION\b/, // Regression /\bREGRESSION\b/, // Regression
/\bMUST\s*(?:HAVE|FIX|DO)\b/, // Must have/fix/do /\bMUST\s*(?:HAVE|FIX|DO)\b/, // Must have/fix/do
/\bSEVERITY\s*2\b/, // Severity 2 /\bSEVERITY\s*2\b/, // Severity 2
/\bREQUIRED\b/, // Required /\bREQUIRED\b/, // Required
]; ];
/** P2 (Medium) priority patterns - lower priority improvements */ /** P2 (Medium) priority patterns - lower priority improvements */
const P2_PRIORITY_PATTERNS = [ const P2_PRIORITY_PATTERNS = [
/\bP2\b|\(P2\)|:?\s*P2\s*:/, // Explicit P2 /\bP2\b|\(P2\)|:?\s*P2\s*:/, // Explicit P2
/\bNICE\s*TO\s*HAVE\b/, // Nice to have /\bNICE\s*TO\s*HAVE\b/, // Nice to have
/\bLOW\s*PRIORITY\b/, // Low priority /\bLOW\s*PRIORITY\b/, // Low priority
/\bREFACTOR\b/, // Refactor /\bREFACTOR\b/, // Refactor
/\bCLEANUP\b/, // Cleanup /\bCLEANUP\b/, // Cleanup
/\bIMPROVE(?:MENT)?\b/, // Improve/Improvement /\bIMPROVE(?:MENT)?\b/, // Improve/Improvement
/\bOPTIMIZ(?:E|ATION)\b/, // Optimize/Optimization /\bOPTIMIZ(?:E|ATION)\b/, // Optimize/Optimization
/\bCONSIDER\b/, // Consider /\bCONSIDER\b/, // Consider
/\bWOULD\s*BE\s*NICE\b/, // Would be nice /\bWOULD\s*BE\s*NICE\b/, // Would be nice
/\bENHANCE(?:MENT)?\b/, // Enhance/Enhancement /\bENHANCE(?:MENT)?\b/, // Enhance/Enhancement
/\bTECH(?:NICAL)?\s*DEBT\b/, // Tech debt /\bTECH(?:NICAL)?\s*DEBT\b/, // Tech debt
/\bDOCUMENT(?:ATION)?\b/, // Documentation /\bDOCUMENT(?:ATION)?\b/, // Documentation
]; ];
// ========== Event Types ========== // ========== Event Types ==========
@@ -950,7 +958,7 @@ export class RalphTracker extends EventEmitter {
const wasEnabled = this._loopState.enabled; const wasEnabled = this._loopState.enabled;
this._loopState = createInitialRalphTrackerState(); this._loopState = createInitialRalphTrackerState();
this._loopState.enabled = wasEnabled; // Keep enabled status this._loopState.enabled = wasEnabled; // Keep enabled status
this._todos.clear(); this._todos.clear();
this._completionPhraseCount.clear(); this._completionPhraseCount.clear();
this._taskNumberToContent.clear(); this._taskNumberToContent.clear();
@@ -1241,7 +1249,7 @@ export class RalphTracker extends EventEmitter {
// Check if all todos are complete (adds 20 points) // Check if all todos are complete (adds 20 points)
const todoArray = Array.from(this._todos.values()); const todoArray = Array.from(this._todos.values());
if (todoArray.length > 0 && todoArray.every(t => t.status === 'completed')) { if (todoArray.length > 0 && todoArray.every((t) => t.status === 'completed')) {
signals.allTodosComplete = true; signals.allTodosComplete = true;
score += 20; score += 20;
} }
@@ -1262,10 +1270,12 @@ export class RalphTracker extends EventEmitter {
if (context) { if (context) {
const lowerContext = context.toLowerCase(); const lowerContext = context.toLowerCase();
// Deduct points if phrase appears in prompt-like context // Deduct points if phrase appears in prompt-like context
if (lowerContext.includes('output:') || if (
lowerContext.includes('completion phrase') || lowerContext.includes('output:') ||
lowerContext.includes('output exactly') || lowerContext.includes('completion phrase') ||
lowerContext.includes('when done')) { lowerContext.includes('output exactly') ||
lowerContext.includes('when done')
) {
signals.contextAppropriate = false; signals.contextAppropriate = false;
score -= 20; score -= 20;
} else { } else {
@@ -1335,7 +1345,6 @@ export class RalphTracker extends EventEmitter {
* Use this when the caller has already stripped ANSI to avoid redundant regex work. * Use this when the caller has already stripped ANSI to avoid redundant regex work.
*/ */
processCleanData(cleanData: string): void { processCleanData(cleanData: string): void {
// If tracker is disabled, only check for patterns that should auto-enable it // If tracker is disabled, only check for patterns that should auto-enable it
if (!this._loopState.enabled) { if (!this._loopState.enabled) {
// Don't auto-enable if explicitly disabled by user setting // Don't auto-enable if explicitly disabled by user setting
@@ -1396,14 +1405,21 @@ export class RalphTracker extends EventEmitter {
// substrings that any pattern could match are present in the data. // substrings that any pattern could match are present in the data.
// This avoids 12 regex tests on every PTY chunk (the common case). // This avoids 12 regex tests on every PTY chunk (the common case).
if ( if (
!data.includes('<') && // <promise>, TodoWrite !data.includes('<') && // <promise>, TodoWrite
!data.includes('ralph') && !data.includes('Ralph') && !data.includes('ralph') &&
!data.includes('Todo') && !data.includes('todo') && !data.includes('Ralph') &&
!data.includes('Iteration') && !data.includes('[') && !data.includes('Todo') &&
!data.includes('\u2610') && !data.includes('\u2612') && // ☐ ☒ !data.includes('todo') &&
!data.includes('Iteration') &&
!data.includes('[') &&
!data.includes('\u2610') &&
!data.includes('\u2612') && // ☐ ☒
!data.includes('\u2714') && // ✔ !data.includes('\u2714') && // ✔
!data.includes('Loop') && !data.includes('complete') && !data.includes('Loop') &&
!data.includes('COMPLETE') && !data.includes('Done') && !data.includes('DONE') !data.includes('complete') &&
!data.includes('COMPLETE') &&
!data.includes('Done') &&
!data.includes('DONE')
) { ) {
return false; return false;
} }
@@ -1677,8 +1693,8 @@ export class RalphTracker extends EventEmitter {
// Avoid false positives: don't trigger on prompt context // Avoid false positives: don't trigger on prompt context
const isNotInPromptContext = !line.includes('<promise>') && !line.includes('output:'); const isNotInPromptContext = !line.includes('<promise>') && !line.includes('output:');
// Also avoid triggering on "completion phrase is X" explanatory text // Also avoid triggering on "completion phrase is X" explanatory text
const isNotExplanation = !line.toLowerCase().includes('completion phrase') && const isNotExplanation =
!line.toLowerCase().includes('output exactly'); !line.toLowerCase().includes('completion phrase') && !line.toLowerCase().includes('output exactly');
if (isNotInPromptContext && isNotExplanation) { if (isNotInPromptContext && isNotExplanation) {
this.handleBareCompletionPhrase(expectedPhrase); this.handleBareCompletionPhrase(expectedPhrase);
@@ -1786,7 +1802,7 @@ export class RalphTracker extends EventEmitter {
if (matchedPhrase) { if (matchedPhrase) {
// Use the matched phrase (canonical) for tracking // Use the matched phrase (canonical) for tracking
const canonicalCount = (this._completionPhraseCount.get(matchedPhrase) || 0); const canonicalCount = this._completionPhraseCount.get(matchedPhrase) || 0;
// Require 2nd+ occurrence of canonical phrase OR explicitly active loop. // Require 2nd+ occurrence of canonical phrase OR explicitly active loop.
// First occurrence (count=1) is the prompt echo — not actual completion. // First occurrence (count=1) is the prompt echo — not actual completion.
if (canonicalCount >= 2 || this._loopState.active) { if (canonicalCount >= 2 || this._loopState.active) {
@@ -1862,7 +1878,7 @@ export class RalphTracker extends EventEmitter {
* @fires phraseValidationWarning - When a risky phrase is detected * @fires phraseValidationWarning - When a risky phrase is detected
*/ */
private validateCompletionPhrase(phrase: string): void { private validateCompletionPhrase(phrase: string): void {
const normalized = phrase.toUpperCase().replace(/[\s_\-\.]+/g, ''); const normalized = phrase.toUpperCase().replace(/[\s_\-.]+/g, '');
// Generate a suggested unique phrase // Generate a suggested unique phrase
const uniqueSuffix = Date.now().toString(36).slice(-4).toUpperCase(); const uniqueSuffix = Date.now().toString(36).slice(-4).toUpperCase();
@@ -1870,7 +1886,9 @@ export class RalphTracker extends EventEmitter {
// Check for common phrases // Check for common phrases
if (COMMON_COMPLETION_PHRASES.has(normalized)) { if (COMMON_COMPLETION_PHRASES.has(normalized)) {
console.warn(`[RalphTracker] Warning: Completion phrase "${phrase}" is very common and may cause false positives. Consider using: "${suggestedPhrase}"`); console.warn(
`[RalphTracker] Warning: Completion phrase "${phrase}" is very common and may cause false positives. Consider using: "${suggestedPhrase}"`
);
this.emit('phraseValidationWarning', { this.emit('phraseValidationWarning', {
phrase, phrase,
reason: 'common', reason: 'common',
@@ -1881,7 +1899,9 @@ export class RalphTracker extends EventEmitter {
// Check for short phrases // Check for short phrases
if (normalized.length < MIN_RECOMMENDED_PHRASE_LENGTH) { if (normalized.length < MIN_RECOMMENDED_PHRASE_LENGTH) {
console.warn(`[RalphTracker] Warning: Completion phrase "${phrase}" is too short (${normalized.length} chars). Consider using: "${suggestedPhrase}"`); console.warn(
`[RalphTracker] Warning: Completion phrase "${phrase}" is too short (${normalized.length} chars). Consider using: "${suggestedPhrase}"`
);
this.emit('phraseValidationWarning', { this.emit('phraseValidationWarning', {
phrase, phrase,
reason: 'short', reason: 'short',
@@ -1892,7 +1912,9 @@ export class RalphTracker extends EventEmitter {
// Check for numeric-only phrases // Check for numeric-only phrases
if (/^\d+$/.test(normalized)) { if (/^\d+$/.test(normalized)) {
console.warn(`[RalphTracker] Warning: Completion phrase "${phrase}" is numeric-only and may cause false positives. Consider using: "${suggestedPhrase}"`); console.warn(
`[RalphTracker] Warning: Completion phrase "${phrase}" is numeric-only and may cause false positives. Consider using: "${suggestedPhrase}"`
);
this.emit('phraseValidationWarning', { this.emit('phraseValidationWarning', {
phrase, phrase,
reason: 'numeric', reason: 'numeric',
@@ -1971,14 +1993,16 @@ export class RalphTracker extends EventEmitter {
if (currentIter !== this._lastObservedIteration) { if (currentIter !== this._lastObservedIteration) {
this._lastIterationChangeTime = Date.now(); this._lastIterationChangeTime = Date.now();
this._lastObservedIteration = currentIter; this._lastObservedIteration = currentIter;
this._iterationStallWarned = false; // Reset warning on iteration change this._iterationStallWarned = false; // Reset warning on iteration change
// P1-004: Reset circuit breaker on successful iteration progress // P1-004: Reset circuit breaker on successful iteration progress
// If we're making progress, the loop is healthy // If we're making progress, the loop is healthy
if (this._circuitBreaker.state === 'HALF_OPEN' || if (
this._circuitBreaker.consecutiveNoProgress > 0 || this._circuitBreaker.state === 'HALF_OPEN' ||
this._circuitBreaker.consecutiveSameError > 0 || this._circuitBreaker.consecutiveNoProgress > 0 ||
this._circuitBreaker.consecutiveTestsFailure > 0) { this._circuitBreaker.consecutiveSameError > 0 ||
this._circuitBreaker.consecutiveTestsFailure > 0
) {
this._circuitBreaker.consecutiveNoProgress = 0; this._circuitBreaker.consecutiveNoProgress = 0;
this._circuitBreaker.consecutiveSameError = 0; this._circuitBreaker.consecutiveSameError = 0;
this._circuitBreaker.lastProgressIteration = currentIter; this._circuitBreaker.lastProgressIteration = currentIter;
@@ -2106,7 +2130,7 @@ export class RalphTracker extends EventEmitter {
const content = match[2].trim(); const content = match[2].trim();
// Skip if content matches exclude patterns (tool invocations, commentary) // Skip if content matches exclude patterns (tool invocations, commentary)
const shouldExclude = TODO_EXCLUDE_PATTERNS.some(pattern => pattern.test(content)); const shouldExclude = TODO_EXCLUDE_PATTERNS.some((pattern) => pattern.test(content));
if (shouldExclude) continue; if (shouldExclude) continue;
// Skip if content is too short or looks like partial garbage // Skip if content is too short or looks like partial garbage
@@ -2155,9 +2179,8 @@ export class RalphTracker extends EventEmitter {
while ((match = TODO_TASK_STATUS_PATTERN.exec(line)) !== null) { while ((match = TODO_TASK_STATUS_PATTERN.exec(line)) !== null) {
const taskNum = parseInt(match[1], 10); const taskNum = parseInt(match[1], 10);
const statusStr = match[2].trim(); const statusStr = match[2].trim();
const status: RalphTodoStatus = statusStr === 'completed' ? 'completed' const status: RalphTodoStatus =
: statusStr === 'in progress' ? 'in_progress' statusStr === 'completed' ? 'completed' : statusStr === 'in progress' ? 'in_progress' : 'pending';
: 'pending';
const content = this._taskNumberToContent.get(taskNum); const content = this._taskNumberToContent.get(taskNum);
if (content) { if (content) {
this.upsertTodo(content, status); this.upsertTodo(content, status);
@@ -2172,7 +2195,7 @@ export class RalphTracker extends EventEmitter {
while ((match = TODO_PLAIN_CHECKMARK_PATTERN.exec(line)) !== null) { while ((match = TODO_PLAIN_CHECKMARK_PATTERN.exec(line)) !== null) {
const content = match[1].trim(); const content = match[1].trim();
// Skip if content matches exclude patterns // Skip if content matches exclude patterns
const shouldExclude = TODO_EXCLUDE_PATTERNS.some(pattern => pattern.test(content)); const shouldExclude = TODO_EXCLUDE_PATTERNS.some((pattern) => pattern.test(content));
if (shouldExclude) continue; if (shouldExclude) continue;
if (content.length < 5) continue; if (content.length < 5) continue;
// Skip status/created/updated prefixed content (already handled above) // Skip status/created/updated prefixed content (already handled above)
@@ -2204,17 +2227,17 @@ export class RalphTracker extends EventEmitter {
switch (icon) { switch (icon) {
case '✓': case '✓':
case '✅': case '✅':
case '☒': // Claude Code checked checkbox case '☒': // Claude Code checked checkbox
case '◉': // Filled circle (completed) case '◉': // Filled circle (completed)
case '●': // Solid circle (completed) case '●': // Solid circle (completed)
return 'completed'; return 'completed';
case '◐': // Half-filled circle (in progress) case '◐': // Half-filled circle (in progress)
case '⏳': case '⏳':
case '⌛': case '⌛':
case '🔄': case '🔄':
return 'in_progress'; return 'in_progress';
case '☐': // Claude Code empty checkbox case '☐': // Claude Code empty checkbox
case '○': // Empty circle case '○': // Empty circle
default: default:
return 'pending'; return 'pending';
} }
@@ -2280,10 +2303,10 @@ export class RalphTracker extends EventEmitter {
// Clean content: remove ANSI codes, collapse whitespace, trim // Clean content: remove ANSI codes, collapse whitespace, trim
const cleanContent = content const cleanContent = content
.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '') // Remove ANSI escape codes .replace(ANSI_ESCAPE_PATTERN_SIMPLE, '') // Remove ANSI escape codes
.replace(/\s+/g, ' ') // Collapse whitespace .replace(/\s+/g, ' ') // Collapse whitespace
.trim(); .trim();
if (cleanContent.length < 5) return; // Skip very short content if (cleanContent.length < 5) return; // Skip very short content
// Parse priority from content // Parse priority from content
const priority = this.parsePriority(cleanContent); const priority = this.parsePriority(cleanContent);
@@ -2402,8 +2425,8 @@ export class RalphTracker extends EventEmitter {
private normalizeTodoContent(content: string): string { private normalizeTodoContent(content: string): string {
if (!content) return ''; if (!content) return '';
return content return content
.replace(/\s+/g, ' ') // Collapse whitespace .replace(/\s+/g, ' ') // Collapse whitespace
.replace(/[^a-zA-Z0-9\s.,!?'"-]/g, '') // Remove special chars (keep punctuation) .replace(/[^a-zA-Z0-9\s.,!?'"-]/g, '') // Remove special chars (keep punctuation)
.trim() .trim()
.toLowerCase(); .toLowerCase();
} }
@@ -2507,7 +2530,7 @@ export class RalphTracker extends EventEmitter {
if (normalized.length < 30) { if (normalized.length < 30) {
threshold = 0.95; // Very strict for short strings threshold = 0.95; // Very strict for short strings
} else if (normalized.length < 60) { } else if (normalized.length < 60) {
threshold = 0.90; // Strict for medium strings threshold = 0.9; // Strict for medium strings
} else { } else {
threshold = TODO_SIMILARITY_THRESHOLD; // 0.85 for longer strings threshold = TODO_SIMILARITY_THRESHOLD; // 0.85 for longer strings
} }
@@ -2563,14 +2586,7 @@ export class RalphTracker extends EventEmitter {
]; ];
// Moderate: Bugs, features, enhancements // Moderate: Bugs, features, enhancements
const moderatePatterns = [ const moderatePatterns = [/\bbug\b/, /\bfeature\b/, /\benhance(?:ment)?\b/, /\bimplement\b/, /\badd\b/, /\bfix\b/];
/\bbug\b/,
/\bfeature\b/,
/\benhance(?:ment)?\b/,
/\bimplement\b/,
/\badd\b/,
/\bfix\b/,
];
for (const pattern of complexPatterns) { for (const pattern of complexPatterns) {
if (pattern.test(lower)) return 'complex'; if (pattern.test(lower)) return 'complex';
@@ -2609,10 +2625,10 @@ export class RalphTracker extends EventEmitter {
// Default estimates (in ms) based on typical task durations // Default estimates (in ms) based on typical task durations
const defaults = { const defaults = {
trivial: 1 * 60 * 1000, // 1 minute trivial: 1 * 60 * 1000, // 1 minute
simple: 3 * 60 * 1000, // 3 minutes simple: 3 * 60 * 1000, // 3 minutes
moderate: 10 * 60 * 1000, // 10 minutes moderate: 10 * 60 * 1000, // 10 minutes
complex: 30 * 60 * 1000, // 30 minutes complex: 30 * 60 * 1000, // 30 minutes
}; };
return defaults[complexity]; return defaults[complexity];
} }
@@ -2671,9 +2687,9 @@ export class RalphTracker extends EventEmitter {
public getTodoProgress(): RalphTodoProgress { public getTodoProgress(): RalphTodoProgress {
const todos = Array.from(this._todos.values()); const todos = Array.from(this._todos.values());
const total = todos.length; const total = todos.length;
const completed = todos.filter(t => t.status === 'completed').length; const completed = todos.filter((t) => t.status === 'completed').length;
const inProgress = todos.filter(t => t.status === 'in_progress').length; const inProgress = todos.filter((t) => t.status === 'in_progress').length;
const pending = todos.filter(t => t.status === 'pending').length; const pending = todos.filter((t) => t.status === 'pending').length;
const percentComplete = total > 0 ? Math.round((completed / total) * 100) : 0; const percentComplete = total > 0 ? Math.round((completed / total) * 100) : 0;
@@ -2983,7 +2999,7 @@ export class RalphTracker extends EventEmitter {
// Ensure enabled flag exists (backwards compatibility) // Ensure enabled flag exists (backwards compatibility)
this._loopState = { this._loopState = {
...loopState, ...loopState,
enabled: loopState.enabled ?? false, // Override after spread for backwards compat enabled: loopState.enabled ?? false, // Override after spread for backwards compat
}; };
this._todos.clear(); this._todos.clear();
for (const todo of todos) { for (const todo of todos) {
@@ -3068,7 +3084,9 @@ export class RalphTracker extends EventEmitter {
if (!Number.isNaN(value) && value >= 0) { if (!Number.isNaN(value) && value >= 0) {
block.tasksCompletedThisLoop = value; block.tasksCompletedThisLoop = value;
} else { } else {
parseErrors.push(`Invalid TASKS_COMPLETED_THIS_LOOP value: "${tasksMatch[1]}". Expected: non-negative integer`); parseErrors.push(
`Invalid TASKS_COMPLETED_THIS_LOOP value: "${tasksMatch[1]}". Expected: non-negative integer`
);
} }
matched = true; matched = true;
} }
@@ -3104,7 +3122,9 @@ export class RalphTracker extends EventEmitter {
if (['IMPLEMENTATION', 'TESTING', 'DOCUMENTATION', 'REFACTORING'].includes(value)) { if (['IMPLEMENTATION', 'TESTING', 'DOCUMENTATION', 'REFACTORING'].includes(value)) {
block.workType = value as RalphWorkType; block.workType = value as RalphWorkType;
} else { } else {
parseErrors.push(`Invalid WORK_TYPE value: "${value}". Expected: IMPLEMENTATION, TESTING, DOCUMENTATION, or REFACTORING`); parseErrors.push(
`Invalid WORK_TYPE value: "${value}". Expected: IMPLEMENTATION, TESTING, DOCUMENTATION, or REFACTORING`
);
} }
matched = true; matched = true;
} }
@@ -3126,7 +3146,7 @@ export class RalphTracker extends EventEmitter {
// Track unknown fields for debugging (only if looks like a field) // Track unknown fields for debugging (only if looks like a field)
if (!matched && trimmedLine.includes(':')) { if (!matched && trimmedLine.includes(':')) {
const fieldName = trimmedLine.split(':')[0].trim().toUpperCase(); const fieldName = trimmedLine.split(':')[0].trim().toUpperCase();
if (fieldName && !['#', '//'].some(c => fieldName.startsWith(c))) { if (fieldName && !['#', '//'].some((c) => fieldName.startsWith(c))) {
unknownFields.push(fieldName); unknownFields.push(fieldName);
} }
} }
@@ -3221,11 +3241,7 @@ export class RalphTracker extends EventEmitter {
* @param status - Overall status from RALPH_STATUS * @param status - Overall status from RALPH_STATUS
* @fires circuitBreakerUpdate - If state changes * @fires circuitBreakerUpdate - If state changes
*/ */
private updateCircuitBreaker( private updateCircuitBreaker(hasProgress: boolean, testsStatus: RalphTestsStatus, status: RalphStatusValue): void {
hasProgress: boolean,
testsStatus: RalphTestsStatus,
status: RalphStatusValue
): void {
const prevState = this._circuitBreaker.state; const prevState = this._circuitBreaker.state;
if (hasProgress) { if (hasProgress) {
@@ -3319,7 +3335,11 @@ export class RalphTracker extends EventEmitter {
/** /**
* Get cumulative stats from status blocks. * Get cumulative stats from status blocks.
*/ */
get cumulativeStats(): { filesModified: number; tasksCompleted: number; completionIndicators: number } { get cumulativeStats(): {
filesModified: number;
tasksCompleted: number;
completionIndicators: number;
} {
return { return {
filesModified: this._totalFilesModified, filesModified: this._totalFilesModified,
tasksCompleted: this._totalTasksCompleted, tasksCompleted: this._totalTasksCompleted,
@@ -3456,7 +3476,7 @@ export class RalphTracker extends EventEmitter {
const tasksHeaderPattern = /^##\s*Tasks/i; const tasksHeaderPattern = /^##\s*Tasks/i;
// Pattern for todo items // Pattern for todo items
const todoPattern = /^-\s*\[([ x\-])\]\s*(.+)$/; const todoPattern = /^-\s*\[([ x-])\]\s*(.+)$/;
let inCompletedSection = false; let inCompletedSection = false;
@@ -3504,7 +3524,7 @@ export class RalphTracker extends EventEmitter {
} }
// Parse priority from content if not in a priority section // Parse priority from content if not in a priority section
const parsedPriority = inCompletedSection ? null : (currentPriority || this.parsePriority(content)); const parsedPriority = inCompletedSection ? null : currentPriority || this.parsePriority(content);
const id = this.generateTodoId(content); const id = this.generateTodoId(content);
newTodos.push({ newTodos.push({
@@ -3535,17 +3555,19 @@ export class RalphTracker extends EventEmitter {
* Initialize plan tasks from generated plan items. * Initialize plan tasks from generated plan items.
* Called when wizard generates a new plan. * Called when wizard generates a new plan.
*/ */
initializePlanTasks(items: Array<{ initializePlanTasks(
id?: string; items: Array<{
content: string; id?: string;
priority?: 'P0' | 'P1' | 'P2' | null; content: string;
verificationCriteria?: string; priority?: 'P0' | 'P1' | 'P2' | null;
testCommand?: string; verificationCriteria?: string;
dependencies?: string[]; testCommand?: string;
tddPhase?: TddPhase; dependencies?: string[];
pairedWith?: string; tddPhase?: TddPhase;
complexity?: 'low' | 'medium' | 'high'; pairedWith?: string;
}>): void { complexity?: 'low' | 'medium' | 'high';
}>
): void {
// Save current plan to history before replacing // Save current plan to history before replacing
if (this._planTasks.size > 0) { if (this._planTasks.size > 0) {
this._savePlanToHistory('Plan replaced with new generation'); this._savePlanToHistory('Plan replaced with new generation');
@@ -3580,11 +3602,14 @@ export class RalphTracker extends EventEmitter {
/** /**
* Update a specific plan task's status, attempts, or error. * Update a specific plan task's status, attempts, or error.
*/ */
updatePlanTask(taskId: string, update: { updatePlanTask(
status?: PlanTaskStatus; taskId: string,
error?: string; update: {
incrementAttempts?: boolean; status?: PlanTaskStatus;
}): { success: boolean; task?: EnhancedPlanTask; error?: string } { error?: string;
incrementAttempts?: boolean;
}
): { success: boolean; task?: EnhancedPlanTask; error?: string } {
const task = this._planTasks.get(taskId); const task = this._planTasks.get(taskId);
if (!task) { if (!task) {
return { success: false, error: 'Task not found' }; return { success: false, error: 'Task not found' };
@@ -3635,7 +3660,7 @@ export class RalphTracker extends EventEmitter {
for (const [_, task] of this._planTasks) { for (const [_, task] of this._planTasks) {
if (task.dependencies.includes(completedTaskId)) { if (task.dependencies.includes(completedTaskId)) {
// Check if all dependencies are now complete // Check if all dependencies are now complete
const allDepsComplete = task.dependencies.every(depId => { const allDepsComplete = task.dependencies.every((depId) => {
const dep = this._planTasks.get(depId); const dep = this._planTasks.get(depId);
return dep && dep.status === 'completed'; return dep && dep.status === 'completed';
}); });
@@ -3653,8 +3678,7 @@ export class RalphTracker extends EventEmitter {
*/ */
private _checkForCheckpoint(): void { private _checkForCheckpoint(): void {
const currentIteration = this._loopState.cycleCount; const currentIteration = this._loopState.cycleCount;
if (this._checkpointIterations.includes(currentIteration) && if (this._checkpointIterations.includes(currentIteration) && currentIteration > this._lastCheckpointIteration) {
currentIteration > this._lastCheckpointIteration) {
this._lastCheckpointIteration = currentIteration; this._lastCheckpointIteration = currentIteration;
const checkpoint = this.generateCheckpointReview(); const checkpoint = this.generateCheckpointReview();
this.emit('planCheckpoint', checkpoint); this.emit('planCheckpoint', checkpoint);
@@ -3669,17 +3693,17 @@ export class RalphTracker extends EventEmitter {
const summary = { const summary = {
total: tasks.length, total: tasks.length,
completed: tasks.filter(t => t.status === 'completed').length, completed: tasks.filter((t) => t.status === 'completed').length,
failed: tasks.filter(t => t.status === 'failed').length, failed: tasks.filter((t) => t.status === 'failed').length,
blocked: tasks.filter(t => t.status === 'blocked').length, blocked: tasks.filter((t) => t.status === 'blocked').length,
pending: tasks.filter(t => t.status === 'pending').length, pending: tasks.filter((t) => t.status === 'pending').length,
inProgress: tasks.filter(t => t.status === 'in_progress').length, inProgress: tasks.filter((t) => t.status === 'in_progress').length,
}; };
// Find stuck tasks (3+ attempts or blocked) // Find stuck tasks (3+ attempts or blocked)
const stuckTasks = tasks const stuckTasks = tasks
.filter(t => t.attempts >= 3 || t.status === 'blocked') .filter((t) => t.attempts >= 3 || t.status === 'blocked')
.map(t => ({ .map((t) => ({
id: t.id, id: t.id,
content: t.content, content: t.content,
attempts: t.attempts, attempts: t.attempts,
@@ -3697,9 +3721,7 @@ export class RalphTracker extends EventEmitter {
recommendations.push('More tasks have failed than completed. Review approach and consider plan adjustment.'); recommendations.push('More tasks have failed than completed. Review approach and consider plan adjustment.');
} }
const progressPercent = summary.total > 0 const progressPercent = summary.total > 0 ? Math.round((summary.completed / summary.total) * 100) : 0;
? Math.round((summary.completed / summary.total) * 100)
: 0;
if (progressPercent < 20 && this._loopState.cycleCount > 10) { if (progressPercent < 20 && this._loopState.cycleCount > 10) {
recommendations.push('Progress is slow. Consider simplifying tasks or reviewing dependencies.'); recommendations.push('Progress is slow. Consider simplifying tasks or reviewing dependencies.');
} }
@@ -3749,7 +3771,7 @@ export class RalphTracker extends EventEmitter {
summary: string; summary: string;
stats: { total: number; completed: number; failed: number }; stats: { total: number; completed: number; failed: number };
}> { }> {
return this._planHistory.map(h => { return this._planHistory.map((h) => {
const tasks = Array.from(h.tasks.values()); const tasks = Array.from(h.tasks.values());
return { return {
version: h.version, version: h.version,
@@ -3757,8 +3779,8 @@ export class RalphTracker extends EventEmitter {
summary: h.summary, summary: h.summary,
stats: { stats: {
total: tasks.length, total: tasks.length,
completed: tasks.filter(t => t.status === 'completed').length, completed: tasks.filter((t) => t.status === 'completed').length,
failed: tasks.filter(t => t.status === 'failed').length, failed: tasks.filter((t) => t.status === 'failed').length,
}, },
}; };
}); });
@@ -3767,8 +3789,12 @@ export class RalphTracker extends EventEmitter {
/** /**
* Rollback to a previous plan version. * Rollback to a previous plan version.
*/ */
rollbackToVersion(version: number): { success: boolean; plan?: EnhancedPlanTask[]; error?: string } { rollbackToVersion(version: number): {
const historyEntry = this._planHistory.find(h => h.version === version); success: boolean;
plan?: EnhancedPlanTask[];
error?: string;
} {
const historyEntry = this._planHistory.find((h) => h.version === version);
if (!historyEntry) { if (!historyEntry) {
return { success: false, error: `Version ${version} not found in history` }; return { success: false, error: `Version ${version} not found in history` };
} }
@@ -3807,7 +3833,7 @@ export class RalphTracker extends EventEmitter {
// Generate unique ID // Generate unique ID
const existingIds = Array.from(this._planTasks.keys()); const existingIds = Array.from(this._planTasks.keys());
const prefix = task.priority || 'P1'; const prefix = task.priority || 'P1';
let counter = existingIds.filter(id => id.startsWith(prefix)).length + 1; let counter = existingIds.filter((id) => id.startsWith(prefix)).length + 1;
let id = `${prefix}-${String(counter).padStart(3, '0')}`; let id = `${prefix}-${String(counter).padStart(3, '0')}`;
while (this._planTasks.has(id)) { while (this._planTasks.has(id)) {
counter++; counter++;
@@ -3850,8 +3876,7 @@ export class RalphTracker extends EventEmitter {
*/ */
isCheckpointDue(): boolean { isCheckpointDue(): boolean {
const currentIteration = this._loopState.cycleCount; const currentIteration = this._loopState.cycleCount;
return this._checkpointIterations.includes(currentIteration) && return this._checkpointIterations.includes(currentIteration) && currentIteration > this._lastCheckpointIteration;
currentIteration > this._lastCheckpointIteration;
} }
/** /**
+270 -193
View File
@@ -41,15 +41,8 @@ import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle-
import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js'; import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js';
import type { TeamWatcher } from './team-watcher.js'; import type { TeamWatcher } from './team-watcher.js';
import { BufferAccumulator } from './utils/buffer-accumulator.js'; import { BufferAccumulator } from './utils/buffer-accumulator.js';
import { import { ANSI_ESCAPE_PATTERN_SIMPLE, TOKEN_PATTERN, assertNever } from './utils/index.js';
ANSI_ESCAPE_PATTERN_SIMPLE, import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js';
TOKEN_PATTERN,
assertNever,
} from './utils/index.js';
import {
MAX_RESPAWN_BUFFER_SIZE,
TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE,
} from './config/buffer-limits.js';
import type { import type {
RespawnCycleMetrics, RespawnCycleMetrics,
RespawnAggregateMetrics, RespawnAggregateMetrics,
@@ -542,45 +535,45 @@ export interface RespawnEvents {
/** 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)
updatePrompt: 'write a brief progress summary to CLAUDE.md noting what you accomplished, then continue working.', updatePrompt: 'write a brief progress summary to CLAUDE.md noting what you accomplished, then continue working.',
interStepDelayMs: 1000, // 1 second between steps interStepDelayMs: 1000, // 1 second between steps
enabled: true, enabled: true,
sendClear: true, // send /clear after update prompt sendClear: true, // send /clear after update prompt
sendInit: true, // send /init after /clear sendInit: true, // send /init after /clear
completionConfirmMs: 10000, // 10 seconds of silence after completion message completionConfirmMs: 10000, // 10 seconds of silence after completion message
noOutputTimeoutMs: 30000, // 30 seconds fallback if no output at all noOutputTimeoutMs: 30000, // 30 seconds fallback if no output at all
autoAcceptPrompts: true, // auto-accept plan mode prompts (not questions) autoAcceptPrompts: true, // auto-accept plan mode prompts (not questions)
autoAcceptDelayMs: 8000, // 8 seconds before auto-accepting autoAcceptDelayMs: 8000, // 8 seconds before auto-accepting
aiIdleCheckEnabled: true, // use AI to confirm idle state aiIdleCheckEnabled: true, // use AI to confirm idle state
aiIdleCheckModel: 'claude-opus-4-5-20251101', aiIdleCheckModel: 'claude-opus-4-5-20251101',
aiIdleCheckMaxContext: 16000, // ~4k tokens aiIdleCheckMaxContext: 16000, // ~4k tokens
aiIdleCheckTimeoutMs: 90000, // 90 seconds (thinking can be slow) aiIdleCheckTimeoutMs: 90000, // 90 seconds (thinking can be slow)
aiIdleCheckCooldownMs: 180000, // 3 minutes after WORKING verdict aiIdleCheckCooldownMs: 180000, // 3 minutes after WORKING verdict
aiPlanCheckEnabled: true, // use AI to confirm plan mode before auto-accept aiPlanCheckEnabled: true, // use AI to confirm plan mode before auto-accept
aiPlanCheckModel: 'claude-opus-4-5-20251101', aiPlanCheckModel: 'claude-opus-4-5-20251101',
aiPlanCheckMaxContext: 8000, // ~2k tokens (plan mode UI is compact) aiPlanCheckMaxContext: 8000, // ~2k tokens (plan mode UI is compact)
aiPlanCheckTimeoutMs: 60000, // 60 seconds (thinking can be slow) aiPlanCheckTimeoutMs: 60000, // 60 seconds (thinking can be slow)
aiPlanCheckCooldownMs: 30000, // 30 seconds after NOT_PLAN_MODE aiPlanCheckCooldownMs: 30000, // 30 seconds after NOT_PLAN_MODE
stuckStateDetectionEnabled: true, // detect stuck states stuckStateDetectionEnabled: true, // detect stuck states
stuckStateWarningMs: 300000, // 5 minutes warning threshold stuckStateWarningMs: 300000, // 5 minutes warning threshold
stuckStateRecoveryMs: 600000, // 10 minutes recovery threshold stuckStateRecoveryMs: 600000, // 10 minutes recovery threshold
maxStuckRecoveries: 3, // max recovery attempts maxStuckRecoveries: 3, // max recovery attempts
// P2-001: Adaptive timing // P2-001: Adaptive timing
adaptiveTimingEnabled: true, // Use adaptive timing based on historical patterns adaptiveTimingEnabled: true, // Use adaptive timing based on historical patterns
adaptiveMinConfirmMs: 5000, // Minimum 5 seconds adaptiveMinConfirmMs: 5000, // Minimum 5 seconds
adaptiveMaxConfirmMs: 30000, // Maximum 30 seconds adaptiveMaxConfirmMs: 30000, // Maximum 30 seconds
// P2-002: Skip-clear optimization // P2-002: Skip-clear optimization
skipClearWhenLowContext: true, // Skip /clear when token count is low skipClearWhenLowContext: true, // Skip /clear when token count is low
skipClearThresholdPercent: 30, // Skip if below 30% of max context skipClearThresholdPercent: 30, // Skip if below 30% of max context
// P2-004: Cycle metrics // P2-004: Cycle metrics
trackCycleMetrics: true, // Track and persist cycle metrics trackCycleMetrics: true, // Track and persist cycle metrics
// P2-001: Confidence scoring // P2-001: Confidence scoring
minIdleConfidence: 65, // Minimum confidence to trigger idle (0-100) minIdleConfidence: 65, // Minimum confidence to trigger idle (0-100)
confidenceWeightCompletion: 40, // Weight for completion message confidenceWeightCompletion: 40, // Weight for completion message
confidenceWeightSilence: 25, // Weight for output silence confidenceWeightSilence: 25, // Weight for output silence
confidenceWeightTokens: 20, // Weight for token stability confidenceWeightTokens: 20, // Weight for token stability
confidenceWeightNoWorking: 15, // Weight for working pattern absence confidenceWeightNoWorking: 15, // Weight for working pattern absence
}; };
/** /**
@@ -737,7 +730,8 @@ export class RespawnController extends EventEmitter {
// ========== Timer Tracking for UI Countdown Display ========== // ========== Timer Tracking for UI Countdown Display ==========
/** Active timers being tracked for UI display */ /** Active timers being tracked for UI display */
private activeTimers: Map<string, { name: string; startedAt: number; durationMs: number; endsAt: number }> = new Map(); private activeTimers: Map<string, { name: string; startedAt: number; durationMs: number; endsAt: number }> =
new Map();
/** Recent action log entries (for UI display, max 20) */ /** Recent action log entries (for UI display, max 20) */
private recentActions: ActionLogEntry[] = []; private recentActions: ActionLogEntry[] = [];
@@ -818,9 +812,9 @@ export class RespawnController extends EventEmitter {
* Used as secondary signals, not primary detection. * Used as secondary signals, not primary detection.
*/ */
private readonly PROMPT_PATTERNS = [ private readonly PROMPT_PATTERNS = [
'❯', // Standard prompt '❯', // Standard prompt
'\u276f', // Unicode variant '\u276f', // Unicode variant
'⏵', // Claude Code prompt variant '⏵', // Claude Code prompt variant
]; ];
/** /**
@@ -829,15 +823,55 @@ export class RespawnController extends EventEmitter {
* Note: ✻ and ✽ removed - they appear in completion messages too. * Note: ✻ and ✽ removed - they appear in completion messages too.
*/ */
private readonly WORKING_PATTERNS = [ private readonly WORKING_PATTERNS = [
'Thinking', 'Writing', 'Reading', 'Running', 'Searching', 'Thinking',
'Editing', 'Creating', 'Deleting', 'Analyzing', 'Executing', 'Writing',
'Synthesizing', 'Brewing', // Claude's processing indicators 'Reading',
'Compiling', 'Building', 'Installing', 'Fetching', 'Downloading', 'Running',
'Processing', 'Generating', 'Loading', 'Starting', 'Updating', 'Searching',
'Checking', 'Validating', 'Testing', 'Formatting', 'Linting', 'Editing',
'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏', // Spinner chars 'Creating',
'◐', '◓', '◑', '◒', // Alternative spinners 'Deleting',
'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷', // Braille spinners 'Analyzing',
'Executing',
'Synthesizing',
'Brewing', // Claude's processing indicators
'Compiling',
'Building',
'Installing',
'Fetching',
'Downloading',
'Processing',
'Generating',
'Loading',
'Starting',
'Updating',
'Checking',
'Validating',
'Testing',
'Formatting',
'Linting',
'⠋',
'⠙',
'⠹',
'⠸',
'⠼',
'⠴',
'⠦',
'⠧',
'⠇',
'⠏', // Spinner chars
'◐',
'◓',
'◑',
'◒', // Alternative spinners
'⣾',
'⣽',
'⣻',
'⢿',
'⡿',
'⣟',
'⣯',
'⣷', // Braille spinners
]; ];
/** /**
@@ -1200,7 +1234,7 @@ export class RespawnController extends EventEmitter {
const prevState = this._state; const prevState = this._state;
this._state = newState; this._state = newState;
this.stateEnteredAt = Date.now(); this.stateEnteredAt = Date.now();
this.stuckStateWarned = false; // Reset warning for new state this.stuckStateWarned = false; // Reset warning for new state
this.log(`State: ${prevState} → ${newState}`); this.log(`State: ${prevState} → ${newState}`);
this.logAction('state', `${prevState} → ${newState}`); this.logAction('state', `${prevState} → ${newState}`);
this.emit('stateChanged', newState, prevState); this.emit('stateChanged', newState, prevState);
@@ -1518,7 +1552,9 @@ export class RespawnController extends EventEmitter {
this.setState('watching'); this.setState('watching');
} else { } else {
// Real content (not just escape codes or single chars) - cancel confirmation // Real content (not just escape codes or single chars) - cancel confirmation
this.log(`Substantial output during confirmation ("${stripped.substring(0, 40)}..."), cancelling idle detection`); this.log(
`Substantial output during confirmation ("${stripped.substring(0, 40)}..."), cancelling idle detection`
);
this.cancelCompletionConfirm(); this.cancelCompletionConfirm();
} }
return; return;
@@ -1526,7 +1562,7 @@ export class RespawnController extends EventEmitter {
} }
// Legacy fallback: detect prompt characters (still useful for waiting_* states) // Legacy fallback: detect prompt characters (still useful for waiting_* states)
const hasPrompt = this.PROMPT_PATTERNS.some(pattern => data.includes(pattern)); const hasPrompt = this.PROMPT_PATTERNS.some((pattern) => data.includes(pattern));
if (hasPrompt) { if (hasPrompt) {
this.promptDetected = true; this.promptDetected = true;
this.workingDetected = false; this.workingDetected = false;
@@ -1706,7 +1742,7 @@ export class RespawnController extends EventEmitter {
if (this._state === 'stopped') return; if (this._state === 'stopped') return;
const prompt = this.config.kickstartPrompt!; const prompt = this.config.kickstartPrompt!;
this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`); this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`);
await this.session.writeViaMux(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI await this.session.writeViaMux(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
this.emit('stepSent', 'kickstart', prompt); this.emit('stepSent', 'kickstart', prompt);
this.setState('waiting_kickstart'); this.setState('waiting_kickstart');
this.promptDetected = false; this.promptDetected = false;
@@ -1831,11 +1867,16 @@ export class RespawnController extends EventEmitter {
if (this.stuckRecoveryCount < this.config.maxStuckRecoveries) { if (this.stuckRecoveryCount < this.config.maxStuckRecoveries) {
this.stuckRecoveryCount++; this.stuckRecoveryCount++;
this.logAction('stuck', `Recovery attempt ${this.stuckRecoveryCount}/${this.config.maxStuckRecoveries}`); this.logAction('stuck', `Recovery attempt ${this.stuckRecoveryCount}/${this.config.maxStuckRecoveries}`);
this.log(`Stuck-state recovery triggered (state: ${this._state}, duration: ${Math.round(durationMs / 1000)}s, attempt: ${this.stuckRecoveryCount})`); this.log(
`Stuck-state recovery triggered (state: ${this._state}, duration: ${Math.round(durationMs / 1000)}s, attempt: ${this.stuckRecoveryCount})`
);
this.emit('stuckStateRecovery', this._state, durationMs, this.stuckRecoveryCount); this.emit('stuckStateRecovery', this._state, durationMs, this.stuckRecoveryCount);
this.handleStuckStateRecovery(); this.handleStuckStateRecovery();
} else { } else {
this.logAction('stuck', `Max recoveries (${this.config.maxStuckRecoveries}) reached - manual intervention needed`); this.logAction(
'stuck',
`Max recoveries (${this.config.maxStuckRecoveries}) reached - manual intervention needed`
);
this.log(`Stuck-state: max recoveries reached, manual intervention needed`); this.log(`Stuck-state: max recoveries reached, manual intervention needed`);
} }
return; return;
@@ -1950,12 +1991,7 @@ export class RespawnController extends EventEmitter {
* Start a tracked timer with UI countdown support. * Start a tracked timer with UI countdown support.
* Emits timerStarted event and tracks the timer for UI display. * Emits timerStarted event and tracks the timer for UI display.
*/ */
private startTrackedTimer( private startTrackedTimer(name: string, durationMs: number, callback: () => void, reason?: string): NodeJS.Timeout {
name: string,
durationMs: number,
callback: () => void,
reason?: string
): NodeJS.Timeout {
const now = Date.now(); const now = Date.now();
const endsAt = now + durationMs; const endsAt = now + durationMs;
@@ -1989,7 +2025,7 @@ export class RespawnController extends EventEmitter {
*/ */
getActiveTimers(): ActiveTimerInfo[] { getActiveTimers(): ActiveTimerInfo[] {
const now = Date.now(); const now = Date.now();
return Array.from(this.activeTimers.values()).map(t => ({ return Array.from(this.activeTimers.values()).map((t) => ({
name: t.name, name: t.name,
remainingMs: Math.max(0, t.endsAt - now), remainingMs: Math.max(0, t.endsAt - now),
totalMs: t.durationMs, totalMs: t.durationMs,
@@ -2038,7 +2074,7 @@ export class RespawnController extends EventEmitter {
} }
// Check the rolling window (includes current data, catches both complete and split patterns) // Check the rolling window (includes current data, catches both complete and split patterns)
return this.WORKING_PATTERNS.some(pattern => this.workingPatternWindow.includes(pattern)); return this.WORKING_PATTERNS.some((pattern) => this.workingPatternWindow.includes(pattern));
} }
/** /**
@@ -2175,7 +2211,9 @@ 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(`AI check on cooldown (${Math.ceil(this.aiChecker.getCooldownRemainingMs() / 1000)}s remaining), waiting...`); this.log(
`AI check on cooldown (${Math.ceil(this.aiChecker.getCooldownRemainingMs() / 1000)}s remaining), waiting...`
);
return; return;
} }
@@ -2207,67 +2245,70 @@ export class RespawnController extends EventEmitter {
// Get the terminal buffer for analysis // Get the terminal buffer for analysis
const buffer = this.terminalBuffer.value; const buffer = this.terminalBuffer.value;
this.aiChecker.check(buffer).then((result) => { this.aiChecker
// If state changed while checking (e.g., cancelled), ignore result .check(buffer)
if (this._state !== 'ai_checking') { .then((result) => {
this.log(`AI check result ignored (state is now ${this._state})`); // If state changed while checking (e.g., cancelled), ignore result
return; if (this._state !== 'ai_checking') {
} this.log(`AI check result ignored (state is now ${this._state})`);
return;
}
// Validate this is the result for the current check (not a stale one) // Validate this is the result for the current check (not a stale one)
if (this._currentAiCheckId !== checkId) { if (this._currentAiCheckId !== checkId) {
this.log(`AI check result ignored (stale check ID: ${checkId.substring(0, 8)})`); this.log(`AI check result ignored (stale check ID: ${checkId.substring(0, 8)})`);
return; return;
} }
if (result.verdict === 'IDLE') { if (result.verdict === 'IDLE') {
// Cancel any pending confirmation timers - AI has spoken // Cancel any pending confirmation timers - AI has spoken
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: IDLE'); this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: IDLE');
this.completionConfirmTimer = null; this.completionConfirmTimer = null;
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'AI verdict: IDLE'); this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'AI verdict: IDLE');
this.preFilterTimer = null; this.preFilterTimer = null;
this.logAction('ai-check', `Verdict: IDLE - ${result.reasoning}`); this.logAction('ai-check', `Verdict: IDLE - ${result.reasoning}`);
this.emit('aiCheckCompleted', result); this.emit('aiCheckCompleted', result);
this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`); this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`);
} else if (result.verdict === 'WORKING') { } else if (result.verdict === 'WORKING') {
// Cancel timers and go to cooldown // Cancel timers and go to cooldown
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: WORKING'); this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: WORKING');
this.completionConfirmTimer = null; this.completionConfirmTimer = null;
this.logAction('ai-check', `Verdict: WORKING - ${result.reasoning}`); this.logAction('ai-check', `Verdict: WORKING - ${result.reasoning}`);
this.emit('aiCheckCompleted', result); this.emit('aiCheckCompleted', result);
this.setState('watching'); this.setState('watching');
this.log(`AI check says WORKING, returning to watching with ${this.config.aiIdleCheckCooldownMs}ms cooldown`); this.log(`AI check says WORKING, returning to watching with ${this.config.aiIdleCheckCooldownMs}ms cooldown`);
// Restart timers so the controller retries after cooldown expires // Restart timers so the controller retries after cooldown expires
this.startNoOutputTimer(); this.startNoOutputTimer();
this.startPreFilterTimer(); this.startPreFilterTimer();
} else { } else {
// ERROR verdict // ERROR verdict
this.logAction('ai-check', `Error: ${result.reasoning}`); this.logAction('ai-check', `Error: ${result.reasoning}`);
this.emit('aiCheckFailed', result.reasoning); this.emit('aiCheckFailed', result.reasoning);
this.setState('watching'); this.setState('watching');
// Restart timers to allow retry // Restart timers to allow retry
this.startNoOutputTimer(); this.startNoOutputTimer();
this.startPreFilterTimer(); this.startPreFilterTimer();
} }
}).catch((err) => { })
// Validate this is the error for the current check .catch((err) => {
if (this._currentAiCheckId !== checkId) { // Validate this is the error for the current check
return; // Stale check, ignore error if (this._currentAiCheckId !== checkId) {
} return; // Stale check, ignore error
if (this._state === 'stopped') return; // Guard against stopped state }
if (this._state === 'ai_checking') { if (this._state === 'stopped') return; // Guard against stopped state
const errorMsg = err instanceof Error ? err.message : String(err); if (this._state === 'ai_checking') {
this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`); const errorMsg = err instanceof Error ? err.message : String(err);
this.emit('aiCheckFailed', errorMsg); this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`);
this.setState('watching'); this.emit('aiCheckFailed', errorMsg);
this.log(`AI check error: ${errorMsg}`); this.setState('watching');
// Restart timers to allow retry this.log(`AI check error: ${errorMsg}`);
this.startNoOutputTimer(); // Restart timers to allow retry
this.startPreFilterTimer(); this.startNoOutputTimer();
} this.startPreFilterTimer();
}); }
});
} }
// ========== Auto-Accept Prompt Methods ========== // ========== Auto-Accept Prompt Methods ==========
@@ -2351,7 +2392,9 @@ export class RespawnController extends EventEmitter {
// 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(`Skipping auto-accept: plan checker on cooldown (${Math.ceil(this.planChecker.getCooldownRemainingMs() / 1000)}s remaining)`); this.log(
`Skipping auto-accept: plan checker on cooldown (${Math.ceil(this.planChecker.getCooldownRemainingMs() / 1000)}s remaining)`
);
return; return;
} }
if (this.planChecker.status === 'checking') { if (this.planChecker.status === 'checking') {
@@ -2396,7 +2439,7 @@ export class RespawnController extends EventEmitter {
// Working patterns before the selector are from earlier work and don't matter. // Working patterns before the selector are from earlier work and don't matter.
const selectorIndex = stripped.lastIndexOf(selectorMatch[0]); const selectorIndex = stripped.lastIndexOf(selectorMatch[0]);
const afterSelector = stripped.slice(selectorIndex + selectorMatch[0].length); const afterSelector = stripped.slice(selectorIndex + selectorMatch[0].length);
const hasWorking = this.WORKING_PATTERNS.some(pattern => afterSelector.includes(pattern)); const hasWorking = this.WORKING_PATTERNS.some((pattern) => afterSelector.includes(pattern));
if (hasWorking) return false; if (hasWorking) return false;
return true; return true;
@@ -2416,36 +2459,39 @@ export class RespawnController extends EventEmitter {
this.logAction('plan-check', 'Spawning AI plan checker'); this.logAction('plan-check', 'Spawning AI plan checker');
this.emit('planCheckStarted'); this.emit('planCheckStarted');
this.planChecker.check(buffer).then((result) => { this.planChecker
// Discard stale result if new output arrived during check .check(buffer)
if (this.lastOutputTime > this.planCheckStartTime) { .then((result) => {
this.logAction('plan-check', 'Result discarded (output arrived during check)'); // Discard stale result if new output arrived during check
return; if (this.lastOutputTime > this.planCheckStartTime) {
} this.logAction('plan-check', 'Result discarded (output arrived during check)');
if (result.verdict === 'PLAN_MODE') {
// Don't send Enter if state changed (e.g., AI idle check started or respawn cycle began)
if (this._state !== 'watching') {
this.logAction('plan-check', `Verdict: PLAN_MODE but state is ${this._state}, not sending Enter`);
return; return;
} }
this.emit('planCheckCompleted', result);
this.logAction('plan-check', 'Verdict: PLAN_MODE - sending Enter immediately'); if (result.verdict === 'PLAN_MODE') {
this.sendAutoAcceptEnter(); // Don't send Enter if state changed (e.g., AI idle check started or respawn cycle began)
// No cooldown needed - we're taking action if (this._state !== 'watching') {
} else if (result.verdict === 'NOT_PLAN_MODE') { this.logAction('plan-check', `Verdict: PLAN_MODE but state is ${this._state}, not sending Enter`);
this.emit('planCheckCompleted', result); return;
this.logAction('plan-check', `Verdict: NOT_PLAN_MODE - ${result.reasoning}`); }
} else { this.emit('planCheckCompleted', result);
// ERROR verdict this.logAction('plan-check', 'Verdict: PLAN_MODE - sending Enter immediately');
this.emit('planCheckFailed', result.reasoning); this.sendAutoAcceptEnter();
this.logAction('plan-check', `Error: ${result.reasoning}`); // No cooldown needed - we're taking action
} } else if (result.verdict === 'NOT_PLAN_MODE') {
}).catch((err) => { this.emit('planCheckCompleted', result);
const errorMsg = err instanceof Error ? err.message : String(err); this.logAction('plan-check', `Verdict: NOT_PLAN_MODE - ${result.reasoning}`);
this.emit('planCheckFailed', errorMsg); } else {
this.logAction('plan-check', `Failed: ${errorMsg.substring(0, 50)}`); // ERROR verdict
}); this.emit('planCheckFailed', result.reasoning);
this.logAction('plan-check', `Error: ${result.reasoning}`);
}
})
.catch((err) => {
const errorMsg = err instanceof Error ? err.message : String(err);
this.emit('planCheckFailed', errorMsg);
this.logAction('plan-check', `Failed: ${errorMsg.substring(0, 50)}`);
});
} }
/** /**
@@ -2765,11 +2811,13 @@ export class RespawnController extends EventEmitter {
this.log(`Idle confirmed via: ${reason}`); this.log(`Idle confirmed via: ${reason}`);
const status = this.getDetectionStatus(); const status = this.getDetectionStatus();
this.log(`Detection status: confidence=${status.confidenceLevel}%, ` + this.log(
`completion=${status.completionMessageDetected}, ` + `Detection status: confidence=${status.confidenceLevel}%, ` +
`silent=${status.outputSilent}, ` + `completion=${status.completionMessageDetected}, ` +
`tokensStable=${status.tokensStable}, ` + `silent=${status.outputSilent}, ` +
`noWorking=${status.workingPatternsAbsent}`); `tokensStable=${status.tokensStable}, ` +
`noWorking=${status.workingPatternsAbsent}`
);
// ========== Agent Teams Integration ========== // ========== Agent Teams Integration ==========
// Check if session has active teammates — don't respawn while team is working // Check if session has active teammates — don't respawn while team is working
@@ -2777,7 +2825,10 @@ export class RespawnController extends EventEmitter {
const count = this.teamWatcher.getActiveTeammateCount(this.session.id); const count = this.teamWatcher.getActiveTeammateCount(this.session.id);
this.log(`Respawn blocked - ${count} active teammate(s) working`); this.log(`Respawn blocked - ${count} active teammate(s) working`);
this.logAction('team', `Active teammates: ${count}`); this.logAction('team', `Active teammates: ${count}`);
this.emit('respawnBlocked', { reason: 'active_teammates', details: `${count} teammate(s) still working` }); this.emit('respawnBlocked', {
reason: 'active_teammates',
details: `${count} teammate(s) still working`,
});
this.setState('watching'); this.setState('watching');
this.startNoOutputTimer(); this.startNoOutputTimer();
this.startPreFilterTimer(); this.startPreFilterTimer();
@@ -2792,7 +2843,10 @@ export class RespawnController extends EventEmitter {
if (circuitBreaker.state === 'OPEN') { if (circuitBreaker.state === 'OPEN') {
this.log(`Respawn blocked - Circuit breaker OPEN: ${circuitBreaker.reason}`); this.log(`Respawn blocked - Circuit breaker OPEN: ${circuitBreaker.reason}`);
this.logAction('ralph', `Circuit breaker OPEN: ${circuitBreaker.reason}`); this.logAction('ralph', `Circuit breaker OPEN: ${circuitBreaker.reason}`);
this.emit('respawnBlocked', { reason: 'circuit_breaker_open', details: circuitBreaker.reason }); this.emit('respawnBlocked', {
reason: 'circuit_breaker_open',
details: circuitBreaker.reason,
});
this.setState('watching'); this.setState('watching');
// Don't restart timers - wait for manual reset or circuit breaker resolution // Don't restart timers - wait for manual reset or circuit breaker resolution
return; return;
@@ -2804,7 +2858,10 @@ export class RespawnController extends EventEmitter {
if (statusBlock?.exitSignal) { if (statusBlock?.exitSignal) {
this.log(`Respawn paused - RALPH_STATUS EXIT_SIGNAL=true`); this.log(`Respawn paused - RALPH_STATUS EXIT_SIGNAL=true`);
this.logAction('ralph', `Exit signal detected: ${statusBlock.recommendation || 'Task complete'}`); this.logAction('ralph', `Exit signal detected: ${statusBlock.recommendation || 'Task complete'}`);
this.emit('respawnBlocked', { reason: 'exit_signal', details: statusBlock.recommendation || 'Task complete' }); this.emit('respawnBlocked', {
reason: 'exit_signal',
details: statusBlock.recommendation || 'Task complete',
});
this.setState('watching'); this.setState('watching');
// Don't restart timers - loop is complete // Don't restart timers - loop is complete
return; return;
@@ -2814,7 +2871,10 @@ export class RespawnController extends EventEmitter {
if (statusBlock?.status === 'BLOCKED') { if (statusBlock?.status === 'BLOCKED') {
this.log(`Respawn blocked - RALPH_STATUS reports BLOCKED`); this.log(`Respawn blocked - RALPH_STATUS reports BLOCKED`);
this.logAction('ralph', `Claude reported BLOCKED: ${statusBlock.recommendation || 'Needs human intervention'}`); this.logAction('ralph', `Claude reported BLOCKED: ${statusBlock.recommendation || 'Needs human intervention'}`);
this.emit('respawnBlocked', { reason: 'status_blocked', details: statusBlock.recommendation || 'Needs human intervention' }); this.emit('respawnBlocked', {
reason: 'status_blocked',
details: statusBlock.recommendation || 'Needs human intervention',
});
this.setState('watching'); this.setState('watching');
return; return;
} }
@@ -2846,7 +2906,10 @@ export class RespawnController extends EventEmitter {
if (this.session.status === 'error') { if (this.session.status === 'error') {
this.log('Skipping respawn cycle - session is in error state'); this.log('Skipping respawn cycle - session is in error state');
this.logAction('health', 'Respawn skipped: Session error state'); this.logAction('health', 'Respawn skipped: Session error state');
this.emit('respawnBlocked', { reason: 'session_error', details: 'Session is in error state' }); this.emit('respawnBlocked', {
reason: 'session_error',
details: 'Session is in error state',
});
this.setState('watching'); this.setState('watching');
return; return;
} }
@@ -2907,7 +2970,7 @@ export class RespawnController extends EventEmitter {
this.logAction('ralph', `Using RECOMMENDATION: ${rec.substring(0, 50)}...`); this.logAction('ralph', `Using RECOMMENDATION: ${rec.substring(0, 50)}...`);
} }
const input = updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI const input = updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI
this.logAction('command', `Sending: "${updatePrompt.substring(0, 50)}..."`); this.logAction('command', `Sending: "${updatePrompt.substring(0, 50)}..."`);
await this.session.writeViaMux(input); await this.session.writeViaMux(input);
this.emit('stepSent', 'update', updatePrompt); this.emit('stepSent', 'update', updatePrompt);
@@ -2937,7 +3000,7 @@ export class RespawnController extends EventEmitter {
this.stepTimer = null; this.stepTimer = null;
if (this._state === 'stopped') return; if (this._state === 'stopped') return;
this.logAction('command', 'Sending: /clear'); this.logAction('command', 'Sending: /clear');
await this.session.writeViaMux('/clear\r'); // \r triggers Enter in Ink/Claude CLI await this.session.writeViaMux('/clear\r'); // \r triggers Enter in Ink/Claude CLI
this.emit('stepSent', 'clear', '/clear'); this.emit('stepSent', 'clear', '/clear');
this.setState('waiting_clear'); this.setState('waiting_clear');
this.promptDetected = false; this.promptDetected = false;
@@ -2981,7 +3044,7 @@ export class RespawnController extends EventEmitter {
this.stepTimer = null; this.stepTimer = null;
if (this._state === 'stopped') return; if (this._state === 'stopped') return;
this.logAction('command', 'Sending: /init'); this.logAction('command', 'Sending: /init');
await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI
this.emit('stepSent', 'init', '/init'); this.emit('stepSent', 'init', '/init');
this.setState('waiting_init'); this.setState('waiting_init');
this.promptDetected = false; this.promptDetected = false;
@@ -3052,9 +3115,13 @@ export class RespawnController extends EventEmitter {
this.config = { ...this.config, ...filteredConfig }; this.config = { ...this.config, ...filteredConfig };
// Sync AI checker config if relevant fields changed // Sync AI checker config if relevant fields changed
if (config.aiIdleCheckEnabled !== undefined || config.aiIdleCheckModel !== undefined || if (
config.aiIdleCheckMaxContext !== undefined || config.aiIdleCheckTimeoutMs !== undefined || config.aiIdleCheckEnabled !== undefined ||
config.aiIdleCheckCooldownMs !== undefined) { config.aiIdleCheckModel !== undefined ||
config.aiIdleCheckMaxContext !== undefined ||
config.aiIdleCheckTimeoutMs !== undefined ||
config.aiIdleCheckCooldownMs !== undefined
) {
this.aiChecker.updateConfig({ this.aiChecker.updateConfig({
enabled: this.config.aiIdleCheckEnabled, enabled: this.config.aiIdleCheckEnabled,
model: this.config.aiIdleCheckModel, model: this.config.aiIdleCheckModel,
@@ -3065,9 +3132,13 @@ export class RespawnController extends EventEmitter {
} }
// Sync plan checker config if relevant fields changed // Sync plan checker config if relevant fields changed
if (config.aiPlanCheckEnabled !== undefined || config.aiPlanCheckModel !== undefined || if (
config.aiPlanCheckMaxContext !== undefined || config.aiPlanCheckTimeoutMs !== undefined || config.aiPlanCheckEnabled !== undefined ||
config.aiPlanCheckCooldownMs !== undefined) { config.aiPlanCheckModel !== undefined ||
config.aiPlanCheckMaxContext !== undefined ||
config.aiPlanCheckTimeoutMs !== undefined ||
config.aiPlanCheckCooldownMs !== undefined
) {
this.planChecker.updateConfig({ this.planChecker.updateConfig({
enabled: this.config.aiPlanCheckEnabled, enabled: this.config.aiPlanCheckEnabled,
model: this.config.aiPlanCheckModel, model: this.config.aiPlanCheckModel,
@@ -3276,7 +3347,7 @@ export class RespawnController extends EventEmitter {
const now = Date.now(); const now = Date.now();
const metrics: RespawnCycleMetrics = { const metrics: RespawnCycleMetrics = {
...this.currentCycleMetrics as RespawnCycleMetrics, ...(this.currentCycleMetrics as RespawnCycleMetrics),
completedAt: now, completedAt: now,
durationMs: now - (this.currentCycleMetrics.startedAt ?? now), durationMs: now - (this.currentCycleMetrics.startedAt ?? now),
outcome, outcome,
@@ -3299,7 +3370,9 @@ export class RespawnController extends EventEmitter {
// Clear current cycle // Clear current cycle
this.currentCycleMetrics = null; this.currentCycleMetrics = null;
this.log(`Cycle #${metrics.cycleNumber} metrics: ${outcome}, duration=${metrics.durationMs}ms, idle_detection=${metrics.idleDetectionMs}ms`); this.log(
`Cycle #${metrics.cycleNumber} metrics: ${outcome}, duration=${metrics.durationMs}ms, idle_detection=${metrics.idleDetectionMs}ms`
);
} }
/** /**
@@ -3333,8 +3406,8 @@ export class RespawnController extends EventEmitter {
} }
// Recalculate averages using all recent metrics // Recalculate averages using all recent metrics
const durations = this.recentCycleMetrics.map(m => m.durationMs); const durations = this.recentCycleMetrics.map((m) => m.durationMs);
const idleTimes = this.recentCycleMetrics.map(m => m.idleDetectionMs); const idleTimes = this.recentCycleMetrics.map((m) => m.idleDetectionMs);
if (durations.length > 0) { if (durations.length > 0) {
agg.avgCycleDurationMs = Math.round(durations.reduce((a, b) => a + b, 0) / durations.length); agg.avgCycleDurationMs = Math.round(durations.reduce((a, b) => a + b, 0) / durations.length);
@@ -3347,9 +3420,7 @@ export class RespawnController extends EventEmitter {
} }
// Calculate success rate // Calculate success rate
agg.successRate = agg.totalCycles > 0 agg.successRate = agg.totalCycles > 0 ? Math.round((agg.successfulCycles / agg.totalCycles) * 100) : 100;
? Math.round((agg.successfulCycles / agg.totalCycles) * 100)
: 100;
agg.lastUpdatedAt = Date.now(); agg.lastUpdatedAt = Date.now();
} }
@@ -3392,18 +3463,18 @@ export class RespawnController extends EventEmitter {
// Weighted average (cycle success is most important) // Weighted average (cycle success is most important)
const weights = { const weights = {
cycleSuccess: 0.35, cycleSuccess: 0.35,
circuitBreaker: 0.20, circuitBreaker: 0.2,
iterationProgress: 0.20, iterationProgress: 0.2,
aiChecker: 0.15, aiChecker: 0.15,
stuckRecovery: 0.10, stuckRecovery: 0.1,
}; };
const score = Math.round( const score = Math.round(
components.cycleSuccess * weights.cycleSuccess + components.cycleSuccess * weights.cycleSuccess +
components.circuitBreaker * weights.circuitBreaker + components.circuitBreaker * weights.circuitBreaker +
components.iterationProgress * weights.iterationProgress + components.iterationProgress * weights.iterationProgress +
components.aiChecker * weights.aiChecker + components.aiChecker * weights.aiChecker +
components.stuckRecovery * weights.stuckRecovery components.stuckRecovery * weights.stuckRecovery
); );
// Determine status // Determine status
@@ -3446,10 +3517,14 @@ export class RespawnController extends EventEmitter {
const cb = tracker.circuitBreakerStatus; const cb = tracker.circuitBreakerStatus;
switch (cb.state) { switch (cb.state) {
case 'CLOSED': return 100; case 'CLOSED':
case 'HALF_OPEN': return 50; return 100;
case 'OPEN': return 0; case 'HALF_OPEN':
default: return 100; return 50;
case 'OPEN':
return 0;
default:
return 100;
} }
} }
@@ -3527,8 +3602,10 @@ export class RespawnController extends EventEmitter {
status: HealthStatus, status: HealthStatus,
components: RalphLoopHealthScore['components'] components: RalphLoopHealthScore['components']
): string { ): string {
const lowest = Object.entries(components).reduce((min, [key, val]) => const lowest = Object.entries(components).reduce((min, [key, val]) => (val < min.val ? { key, val } : min), {
val < min.val ? { key, val } : min, { key: '', val: 100 }); key: '',
val: 100,
});
if (status === 'excellent') { if (status === 'excellent') {
return `Ralph Loop is operating excellently (${score}/100). All systems healthy.`; return `Ralph Loop is operating excellently (${score}/100). All systems healthy.`;
+17 -25
View File
@@ -139,13 +139,10 @@ export class RunSummaryTracker {
// Record state transition // Record state transition
if (oldState && oldState !== newState) { if (oldState && oldState !== newState) {
this.stats.stateTransitions++; this.stats.stateTransitions++;
this.addEvent( this.addEvent('respawn_state_change', 'info', `State: ${oldState} → ${newState}`, details, {
'respawn_state_change', from: oldState,
'info', to: newState,
`State: ${oldState} → ${newState}`, });
details,
{ from: oldState, to: newState }
);
} }
// Update state tracking // Update state tracking
@@ -214,7 +211,11 @@ export class RunSummaryTracker {
'info', 'info',
`Token milestone: ${this.formatTokens(currentMilestone)}`, `Token milestone: ${this.formatTokens(currentMilestone)}`,
`Input: ${this.formatTokens(inputTokens)}, Output: ${this.formatTokens(outputTokens)}`, `Input: ${this.formatTokens(inputTokens)}, Output: ${this.formatTokens(outputTokens)}`,
{ total, input: inputTokens, output: outputTokens } {
total,
input: inputTokens,
output: outputTokens,
}
); );
} }
} }
@@ -264,13 +265,9 @@ export class RunSummaryTracker {
* Record a Ralph completion detection. * Record a Ralph completion detection.
*/ */
recordRalphCompletion(phrase: string): void { recordRalphCompletion(phrase: string): void {
this.addEvent( this.addEvent('ralph_completion', 'success', 'Ralph completion detected', `Phrase: ${phrase}`, {
'ralph_completion', phrase,
'success', });
'Ralph completion detected',
`Phrase: ${phrase}`,
{ phrase }
);
} }
/** /**
@@ -278,9 +275,7 @@ export class RunSummaryTracker {
*/ */
recordHookEvent(eventType: string, data?: Record<string, unknown>): void { recordHookEvent(eventType: string, data?: Record<string, unknown>): void {
const severity: RunSummaryEventSeverity = const severity: RunSummaryEventSeverity =
eventType === 'stop' ? 'warning' : eventType === 'stop' ? 'warning' : eventType === 'permission_prompt' ? 'info' : 'info';
eventType === 'permission_prompt' ? 'info' :
'info';
this.addEvent( this.addEvent(
'hook_event', 'hook_event',
@@ -311,13 +306,10 @@ export class RunSummaryTracker {
* Record session started. * Record session started.
*/ */
recordSessionStarted(mode: string, workingDir: string): void { recordSessionStarted(mode: string, workingDir: string): void {
this.addEvent( this.addEvent('session_started', 'success', 'Session started', `Mode: ${mode}, Dir: ${workingDir}`, {
'session_started', mode,
'success', workingDir,
'Session started', });
`Mode: ${mode}, Dir: ${workingDir}`,
{ mode, workingDir }
);
} }
/** /**
+1 -1
View File
@@ -62,7 +62,7 @@ export class SessionLifecycleLog {
} }
const lines = raw.trim().split('\n').filter(Boolean); const lines = raw.trim().split('\n').filter(Boolean);
let entries: LifecycleEntry[] = []; const entries: LifecycleEntry[] = [];
// Parse in reverse (newest first) for efficiency with limit // Parse in reverse (newest first) for efficiency with limit
for (let i = lines.length - 1; i >= 0 && entries.length < limit; i--) { for (let i = lines.length - 1; i >= 0 && entries.length < limit; i--) {
+2 -5
View File
@@ -102,7 +102,7 @@ export class SessionManager extends EventEmitter {
// Create a new lock promise that others will wait on // Create a new lock promise that others will wait on
// Define unlock first to ensure it's always in scope before promise assignment // Define unlock first to ensure it's always in scope before promise assignment
let unlock!: () => void; let unlock!: () => void;
const lockPromise = new Promise<void>(resolve => { const lockPromise = new Promise<void>((resolve) => {
unlock = resolve; unlock = resolve;
}); });
this._sessionCreationLock = lockPromise; this._sessionCreationLock = lockPromise;
@@ -196,9 +196,7 @@ export class SessionManager extends EventEmitter {
* Uses Promise.allSettled to ensure all sessions are stopped even if some fail. * Uses Promise.allSettled to ensure all sessions are stopped even if some fail.
*/ */
async stopAllSessions(): Promise<void> { async stopAllSessions(): Promise<void> {
const stopPromises = Array.from(this.sessions.keys()).map((id) => const stopPromises = Array.from(this.sessions.keys()).map((id) => this.stopSession(id));
this.stopSession(id)
);
const results = await Promise.allSettled(stopPromises); const results = await Promise.allSettled(stopPromises);
// Log any failures but don't throw - best effort cleanup // Log any failures but don't throw - best effort cleanup
for (const result of results) { for (const result of results) {
@@ -292,4 +290,3 @@ export function getSessionManager(): SessionManager {
} }
return managerInstance; return managerInstance;
} }
+179 -99
View File
@@ -18,19 +18,26 @@
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
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 { SessionState, SessionStatus, SessionConfig, RalphTrackerState, RalphTodoItem, ActiveBashTool, NiceConfig, DEFAULT_NICE_CONFIG, type ClaudeMode, type SessionMode, type OpenCodeConfig } from './types.js'; import {
SessionState,
SessionStatus,
SessionConfig,
RalphTrackerState,
RalphTodoItem,
ActiveBashTool,
NiceConfig,
DEFAULT_NICE_CONFIG,
type ClaudeMode,
type SessionMode,
type OpenCodeConfig,
} from './types.js';
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js'; import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
import { TaskTracker, type BackgroundTask } from './task-tracker.js'; import { TaskTracker, type BackgroundTask } from './task-tracker.js';
import { RalphTracker } from './ralph-tracker.js'; import { RalphTracker } from './ralph-tracker.js';
import { BashToolParser } from './bash-tool-parser.js'; import { BashToolParser } from './bash-tool-parser.js';
import { BufferAccumulator } from './utils/buffer-accumulator.js'; import { BufferAccumulator } from './utils/buffer-accumulator.js';
import { LRUMap } from './utils/lru-map.js'; import { LRUMap } from './utils/lru-map.js';
import { import { ANSI_ESCAPE_PATTERN_FULL, TOKEN_PATTERN, SPINNER_PATTERN, MAX_SESSION_TOKENS } from './utils/index.js';
ANSI_ESCAPE_PATTERN_FULL,
TOKEN_PATTERN,
SPINNER_PATTERN,
MAX_SESSION_TOKENS,
} from './utils/index.js';
import { import {
MAX_TERMINAL_BUFFER_SIZE, MAX_TERMINAL_BUFFER_SIZE,
TRIM_TERMINAL_TO as TERMINAL_BUFFER_TRIM_SIZE, TRIM_TERMINAL_TO as TERMINAL_BUFFER_TRIM_SIZE,
@@ -68,6 +75,7 @@ const GRACEFUL_SHUTDOWN_DELAY_MS = 100;
// Filter out terminal focus escape sequences (focus in/out reports) // Filter out terminal focus escape sequences (focus in/out reports)
// ^[[I (focus in), ^[[O (focus out), and the enable/disable sequences // ^[[I (focus in), ^[[O (focus out), and the enable/disable sequences
// eslint-disable-next-line no-control-regex
const FOCUS_ESCAPE_FILTER = /\x1b\[\?1004[hl]|\x1b\[[IO]/g; const FOCUS_ESCAPE_FILTER = /\x1b\[\?1004[hl]|\x1b\[[IO]/g;
// Pattern to match Task tool invocations in terminal output // Pattern to match Task tool invocations in terminal output
@@ -78,8 +86,10 @@ const TASK_TOOL_PATTERN = /\b(Explore|Task|Bash|Plan|general-purpose)\(([^)]+)\)
// Pre-compiled patterns for hot paths (avoid regex compilation per call) // Pre-compiled patterns for hot paths (avoid regex compilation per call)
/** Pattern to strip leading ANSI escapes and whitespace from terminal buffer */ /** Pattern to strip leading ANSI escapes and whitespace from terminal buffer */
// eslint-disable-next-line no-control-regex
const LEADING_ANSI_WHITESPACE_PATTERN = /^(\x1b\[\??[\d;]*[A-Za-z]|[\s\r\n])+/; const LEADING_ANSI_WHITESPACE_PATTERN = /^(\x1b\[\??[\d;]*[A-Za-z]|[\s\r\n])+/;
/** Pattern to match Ctrl+L (form feed) characters */ /** Pattern to match Ctrl+L (form feed) characters */
// eslint-disable-next-line no-control-regex
const CTRL_L_PATTERN = /\x0c/g; const CTRL_L_PATTERN = /\x0c/g;
/** Pattern to split by newlines (CR or LF) */ /** Pattern to split by newlines (CR or LF) */
const NEWLINE_SPLIT_PATTERN = /\r?\n/; const NEWLINE_SPLIT_PATTERN = /\r?\n/;
@@ -96,11 +106,7 @@ import { getAugmentedPath } from './utils/claude-cli-resolver.js';
* @param operation - Description of the operation for error messages * @param operation - Description of the operation for error messages
* @returns Promise that resolves/rejects with the original result or timeout error * @returns Promise that resolves/rejects with the original result or timeout error
*/ */
function withTimeout<T>( function withTimeout<T>(promise: Promise<T>, timeoutMs: number, operation: string): Promise<T> {
promise: Promise<T>,
timeoutMs: number,
operation: string
): Promise<T> {
let timeoutId: NodeJS.Timeout; let timeoutId: NodeJS.Timeout;
const timeoutPromise = new Promise<never>((_, reject) => { const timeoutPromise = new Promise<never>((_, reject) => {
@@ -193,7 +199,12 @@ export interface SessionEvents {
/** Active Bash tools list updated */ /** Active Bash tools list updated */
bashToolsUpdate: (tools: ActiveBashTool[]) => void; bashToolsUpdate: (tools: ActiveBashTool[]) => void;
/** CLI info (version, model, account) updated */ /** CLI info (version, model, account) updated */
cliInfoUpdated: (info: { version: string | null; model: string | null; accountType: string | null; latestVersion: string | null }) => void; cliInfoUpdated: (info: {
version: string | null;
model: string | null;
accountType: string | null;
latestVersion: string | null;
}) => void;
} }
// SessionMode is imported from types.ts (single source of truth) // SessionMode is imported from types.ts (single source of truth)
@@ -258,7 +269,7 @@ export class Session extends EventEmitter {
private _lineBufferFlushTimer: NodeJS.Timeout | null = null; private _lineBufferFlushTimer: NodeJS.Timeout | null = null;
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null; private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
private rejectPromise: ((reason: Error) => void) | null = null; private rejectPromise: ((reason: Error) => void) | null = null;
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
private _isWorking: boolean = false; private _isWorking: boolean = false;
private _lastPromptTime: number = 0; private _lastPromptTime: number = 0;
private activityTimeout: NodeJS.Timeout | null = null; private activityTimeout: NodeJS.Timeout | null = null;
@@ -356,7 +367,9 @@ export class Session extends EventEmitter {
// Task descriptions parsed from terminal output (e.g., "Explore(Description)") // Task descriptions parsed from terminal output (e.g., "Explore(Description)")
// Used to correlate with SubagentWatcher discoveries for better window titles // Used to correlate with SubagentWatcher discoveries for better window titles
// Uses LRUMap for automatic eviction at MAX_TASK_DESCRIPTIONS limit // Uses LRUMap for automatic eviction at MAX_TASK_DESCRIPTIONS limit
private _recentTaskDescriptions: LRUMap<number, string> = new LRUMap({ maxSize: Session.MAX_TASK_DESCRIPTIONS }); private _recentTaskDescriptions: LRUMap<number, string> = new LRUMap({
maxSize: Session.MAX_TASK_DESCRIPTIONS,
});
// Throttle expensive PTY processing (Ralph, bash parser, task descriptions) // Throttle expensive PTY processing (Ralph, bash parser, task descriptions)
// Accumulates clean data between processing windows to avoid running regex on every chunk // Accumulates clean data between processing windows to avoid running regex on every chunk
@@ -365,26 +378,28 @@ export class Session extends EventEmitter {
private _expensiveProcessTimer: NodeJS.Timeout | null = null; private _expensiveProcessTimer: NodeJS.Timeout | null = null;
private static readonly EXPENSIVE_PROCESS_INTERVAL_MS = 150; // Process at most every 150ms private static readonly EXPENSIVE_PROCESS_INTERVAL_MS = 150; // Process at most every 150ms
constructor(config: Partial<SessionConfig> & { constructor(
workingDir: string; config: Partial<SessionConfig> & {
mode?: SessionMode; workingDir: string;
name?: string; mode?: SessionMode;
/** Terminal multiplexer instance (tmux) */ name?: string;
mux?: TerminalMultiplexer; /** Terminal multiplexer instance (tmux) */
/** Whether to use multiplexer wrapping */ mux?: TerminalMultiplexer;
useMux?: boolean; /** Whether to use multiplexer wrapping */
/** Existing mux session for restored sessions */ useMux?: boolean;
muxSession?: MuxSession; /** Existing mux session for restored sessions */
niceConfig?: NiceConfig; // Nice prioritying configuration muxSession?: MuxSession;
/** Claude model override (e.g., 'opus', 'sonnet', 'haiku') */ niceConfig?: NiceConfig; // Nice prioritying configuration
model?: string; /** Claude model override (e.g., 'opus', 'sonnet', 'haiku') */
/** Claude CLI startup permission mode */ model?: string;
claudeMode?: ClaudeMode; /** Claude CLI startup permission mode */
/** Comma-separated allowed tools (for 'allowedTools' mode) */ claudeMode?: ClaudeMode;
allowedTools?: string; /** Comma-separated allowed tools (for 'allowedTools' mode) */
/** OpenCode configuration (only for mode === 'opencode') */ allowedTools?: string;
openCodeConfig?: OpenCodeConfig; /** OpenCode configuration (only for mode === 'opencode') */
}) { openCodeConfig?: OpenCodeConfig;
}
) {
super(); super();
this.setMaxListeners(25); this.setMaxListeners(25);
@@ -472,7 +487,6 @@ export class Session extends EventEmitter {
this._bashToolParser.on('toolStart', this._bashToolHandlers.toolStart); this._bashToolParser.on('toolStart', this._bashToolHandlers.toolStart);
this._bashToolParser.on('toolEnd', this._bashToolHandlers.toolEnd); this._bashToolParser.on('toolEnd', this._bashToolHandlers.toolEnd);
this._bashToolParser.on('toolsUpdate', this._bashToolHandlers.toolsUpdate); this._bashToolParser.on('toolsUpdate', this._bashToolHandlers.toolsUpdate);
} }
get status(): SessionStatus { get status(): SessionStatus {
@@ -673,17 +687,23 @@ export class Session extends EventEmitter {
restoreTokens(inputTokens: number, outputTokens: number, totalCost: number): void { restoreTokens(inputTokens: number, outputTokens: number, totalCost: number): void {
// Sanity check: reject absurdly large individual values // Sanity check: reject absurdly large individual values
if (inputTokens > MAX_SESSION_TOKENS || outputTokens > MAX_SESSION_TOKENS) { if (inputTokens > MAX_SESSION_TOKENS || outputTokens > MAX_SESSION_TOKENS) {
console.warn(`[Session ${this.id}] Rejected absurd restored tokens: input=${inputTokens}, output=${outputTokens}`); console.warn(
`[Session ${this.id}] Rejected absurd restored tokens: input=${inputTokens}, output=${outputTokens}`
);
return; return;
} }
// Check token sum doesn't overflow MAX_SESSION_TOKENS // Check token sum doesn't overflow MAX_SESSION_TOKENS
if (inputTokens + outputTokens > MAX_SESSION_TOKENS) { if (inputTokens + outputTokens > MAX_SESSION_TOKENS) {
console.warn(`[Session ${this.id}] Rejected token sum overflow: input=${inputTokens} + output=${outputTokens} = ${inputTokens + outputTokens} > ${MAX_SESSION_TOKENS}`); console.warn(
`[Session ${this.id}] Rejected token sum overflow: input=${inputTokens} + output=${outputTokens} = ${inputTokens + outputTokens} > ${MAX_SESSION_TOKENS}`
);
return; return;
} }
// Reject negative values // Reject negative values
if (inputTokens < 0 || outputTokens < 0 || totalCost < 0) { if (inputTokens < 0 || outputTokens < 0 || totalCost < 0) {
console.warn(`[Session ${this.id}] Rejected negative restored tokens: input=${inputTokens}, output=${outputTokens}, cost=${totalCost}`); console.warn(
`[Session ${this.id}] Rejected negative restored tokens: input=${inputTokens}, output=${outputTokens}, cost=${totalCost}`
);
return; return;
} }
@@ -722,7 +742,9 @@ export class Session extends EventEmitter {
if (threshold !== undefined) { if (threshold !== undefined) {
// Validate threshold bounds // Validate threshold bounds
if (threshold < Session.MIN_AUTO_THRESHOLD || threshold > Session.MAX_AUTO_THRESHOLD) { if (threshold < Session.MIN_AUTO_THRESHOLD || threshold > Session.MAX_AUTO_THRESHOLD) {
console.warn(`[Session ${this.id}] Invalid autoClear threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_CLEAR_THRESHOLD}.`); console.warn(
`[Session ${this.id}] Invalid autoClear threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_CLEAR_THRESHOLD}.`
);
this._autoClearThreshold = Session.DEFAULT_AUTO_CLEAR_THRESHOLD; this._autoClearThreshold = Session.DEFAULT_AUTO_CLEAR_THRESHOLD;
} else { } else {
this._autoClearThreshold = threshold; this._autoClearThreshold = threshold;
@@ -747,7 +769,9 @@ export class Session extends EventEmitter {
if (threshold !== undefined) { if (threshold !== undefined) {
// Validate threshold bounds // Validate threshold bounds
if (threshold < Session.MIN_AUTO_THRESHOLD || threshold > Session.MAX_AUTO_THRESHOLD) { if (threshold < Session.MIN_AUTO_THRESHOLD || threshold > Session.MAX_AUTO_THRESHOLD) {
console.warn(`[Session ${this.id}] Invalid autoCompact threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_COMPACT_THRESHOLD}.`); console.warn(
`[Session ${this.id}] Invalid autoCompact threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_COMPACT_THRESHOLD}.`
);
this._autoCompactThreshold = Session.DEFAULT_AUTO_COMPACT_THRESHOLD; this._autoCompactThreshold = Session.DEFAULT_AUTO_COMPACT_THRESHOLD;
} else { } else {
this._autoCompactThreshold = threshold; this._autoCompactThreshold = threshold;
@@ -911,7 +935,9 @@ export class Session extends EventEmitter {
this._lastActivityAt = Date.now(); this._lastActivityAt = Date.now();
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : 'Claude'; const modeLabel = this.mode === 'opencode' ? 'OpenCode' : 'Claude';
console.log(`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '')); console.log(
`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '')
);
// 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) {
@@ -928,16 +954,21 @@ export class Session extends EventEmitter {
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) { if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName); console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
const newPid = await this._mux.respawnPane({ const newPid = await this._mux.respawnPane({
sessionId: this.id, workingDir: this.workingDir, mode: this.mode, sessionId: this.id,
niceConfig: this._niceConfig, model: this._model, claudeMode: this._claudeMode, workingDir: this.workingDir,
allowedTools: this._allowedTools, openCodeConfig: this._openCodeConfig, mode: this.mode,
niceConfig: this._niceConfig,
model: this._model,
claudeMode: this._claudeMode,
allowedTools: this._allowedTools,
openCodeConfig: this._openCodeConfig,
}); });
if (!newPid) { if (!newPid) {
console.error('[Session] Failed to respawn pane, will create new session'); console.error('[Session] Failed to respawn pane, will create new session');
needsNewSession = true; needsNewSession = true;
} else { } else {
// Wait a moment for the respawned process to fully start // Wait a moment for the respawned process to fully start
await new Promise(resolve => setTimeout(resolve, MUX_STARTUP_DELAY_MS)); await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
} }
} }
@@ -948,9 +979,14 @@ export class Session extends EventEmitter {
} else { } else {
// Create a new mux session // Create a new mux session
this._muxSession = await this._mux.createSession({ this._muxSession = await this._mux.createSession({
sessionId: this.id, workingDir: this.workingDir, mode: this.mode, sessionId: this.id,
name: this._name, niceConfig: this._niceConfig, model: this._model, workingDir: this.workingDir,
claudeMode: this._claudeMode, allowedTools: this._allowedTools, mode: this.mode,
name: this._name,
niceConfig: this._niceConfig,
model: this._model,
claudeMode: this._claudeMode,
allowedTools: this._allowedTools,
openCodeConfig: this._openCodeConfig, openCodeConfig: this._openCodeConfig,
}); });
console.log('[Session] Created mux session:', this._muxSession.muxName); console.log('[Session] Created mux session:', this._muxSession.muxName);
@@ -962,13 +998,21 @@ export class Session extends EventEmitter {
this.ptyProcess = pty.spawn( this.ptyProcess = pty.spawn(
this._mux.getAttachCommand(), this._mux.getAttachCommand(),
this._mux.getAttachArgs(this._muxSession!.muxName), this._mux.getAttachArgs(this._muxSession!.muxName),
{ {
name: 'xterm-256color', name: 'xterm-256color',
cols: 120, cols: 120,
rows: 40, rows: 40,
cwd: this.workingDir, cwd: this.workingDir,
env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', TERM: 'xterm-256color', COLORTERM: undefined, CLAUDECODE: undefined }, env: {
}); ...process.env,
LANG: 'en_US.UTF-8',
LC_ALL: 'en_US.UTF-8',
TERM: 'xterm-256color',
COLORTERM: undefined,
CLAUDECODE: undefined,
},
}
);
// Set claudeSessionId immediately since we passed --session-id to Claude // Set claudeSessionId immediately since we passed --session-id to Claude
// The mux manager passes --session-id ${sessionId} to Claude // The mux manager passes --session-id ${sessionId} to Claude
@@ -1010,9 +1054,7 @@ export class Session extends EventEmitter {
// Clean the buffer - remove mux init junk before actual content // Clean the buffer - remove mux init junk before actual content
// Strip: cursor movement (\x1b[nA/B/C/D), positioning (\x1b[n;nH), // Strip: cursor movement (\x1b[nA/B/C/D), positioning (\x1b[n;nH),
// clear screen (\x1b[2J), scroll region (\x1b[n;nr), and whitespace // clear screen (\x1b[2J), scroll region (\x1b[n;nr), and whitespace
this._terminalBuffer.set( this._terminalBuffer.set(bufferValue.replace(LEADING_ANSI_WHITESPACE_PATTERN, ''));
bufferValue.replace(LEADING_ANSI_WHITESPACE_PATTERN, '')
);
// Signal client to refresh // Signal client to refresh
this.emit('clearTerminal'); this.emit('clearTerminal');
} }
@@ -1081,9 +1123,7 @@ export class Session extends EventEmitter {
this.ptyProcess.onData((rawData: string) => { this.ptyProcess.onData((rawData: string) => {
// Filter out focus escape sequences and Ctrl+L (form feed) // Filter out focus escape sequences and Ctrl+L (form feed)
const data = rawData const data = rawData.replace(FOCUS_ESCAPE_FILTER, '').replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L
.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 // BufferAccumulator handles auto-trimming when max size exceeded
@@ -1252,8 +1292,12 @@ export class Session extends EventEmitter {
// Only check if spinner didn't already trigger working state // Only check if spinner didn't already trigger working state
if (!this._isWorking) { if (!this._isWorking) {
const cleanData = getCleanData(); const cleanData = getCleanData();
if (cleanData.includes('Thinking') || cleanData.includes('Writing') || if (
cleanData.includes('Reading') || cleanData.includes('Running')) { cleanData.includes('Thinking') ||
cleanData.includes('Writing') ||
cleanData.includes('Reading') ||
cleanData.includes('Running')
) {
this._isWorking = true; this._isWorking = true;
this._status = 'busy'; this._status = 'busy';
this.emit('working'); this.emit('working');
@@ -1293,7 +1337,10 @@ export class Session extends EventEmitter {
// 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';
console.log('[Session] Starting shell session with:', shell + (this._useMux ? ` (with ${this._mux!.backend})` : '')); console.log(
'[Session] Starting shell session with:',
shell + (this._useMux ? ` (with ${this._mux!.backend})` : '')
);
// 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) {
@@ -1308,12 +1355,17 @@ export class Session extends EventEmitter {
let needsNewSession = false; let needsNewSession = false;
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) { if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName); console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
const newPid = await this._mux.respawnPane({ sessionId: this.id, workingDir: this.workingDir, mode: 'shell', niceConfig: this._niceConfig }); const newPid = await this._mux.respawnPane({
sessionId: this.id,
workingDir: this.workingDir,
mode: 'shell',
niceConfig: this._niceConfig,
});
if (!newPid) { if (!newPid) {
console.error('[Session] Failed to respawn pane, will create new session'); console.error('[Session] Failed to respawn pane, will create new session');
needsNewSession = true; needsNewSession = true;
} else { } else {
await new Promise(resolve => setTimeout(resolve, MUX_STARTUP_DELAY_MS)); await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
} }
} }
@@ -1323,7 +1375,13 @@ export class Session extends EventEmitter {
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName); console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
} else { } else {
// Create a new mux session // Create a new mux session
this._muxSession = await this._mux.createSession({ sessionId: this.id, workingDir: this.workingDir, mode: 'shell', name: this._name, niceConfig: this._niceConfig }); this._muxSession = await this._mux.createSession({
sessionId: this.id,
workingDir: this.workingDir,
mode: 'shell',
name: this._name,
niceConfig: this._niceConfig,
});
console.log('[Session] Created mux session:', this._muxSession.muxName); console.log('[Session] Created mux session:', this._muxSession.muxName);
// No extra sleep — createSession() already waits for tmux readiness // No extra sleep — createSession() already waits for tmux readiness
} }
@@ -1333,13 +1391,21 @@ export class Session extends EventEmitter {
this.ptyProcess = pty.spawn( this.ptyProcess = pty.spawn(
this._mux.getAttachCommand(), this._mux.getAttachCommand(),
this._mux.getAttachArgs(this._muxSession!.muxName), this._mux.getAttachArgs(this._muxSession!.muxName),
{ {
name: 'xterm-256color', name: 'xterm-256color',
cols: 120, cols: 120,
rows: 40, rows: 40,
cwd: this.workingDir, cwd: this.workingDir,
env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', TERM: 'xterm-256color', COLORTERM: undefined, CLAUDECODE: undefined }, env: {
}); ...process.env,
LANG: 'en_US.UTF-8',
LC_ALL: 'en_US.UTF-8',
TERM: 'xterm-256color',
COLORTERM: undefined,
CLAUDECODE: undefined,
},
}
);
} catch (spawnErr) { } catch (spawnErr) {
console.error('[Session] Failed to spawn PTY for shell mux attachment:', spawnErr); console.error('[Session] Failed to spawn PTY for shell mux attachment:', spawnErr);
this.emit('error', `Failed to attach to mux session: ${spawnErr}`); this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
@@ -1474,7 +1540,7 @@ export class Session extends EventEmitter {
this._messages = []; this._messages = [];
this._lineBuffer = ''; this._lineBuffer = '';
this._lastActivityAt = Date.now(); this._lastActivityAt = Date.now();
this._promptResolved = false; // Reset race condition guard this._promptResolved = false; // Reset race condition guard
this.resolvePromise = resolve; this.resolvePromise = resolve;
this.rejectPromise = reject; this.rejectPromise = reject;
@@ -1482,14 +1548,13 @@ export class Session extends EventEmitter {
try { try {
// Spawn claude in a real PTY // Spawn claude in a real PTY
const model = options?.model; const model = options?.model;
console.log('[Session] Spawning PTY for claude with prompt:', prompt.substring(0, 50), model ? `(model: ${model})` : ''); console.log(
'[Session] Spawning PTY for claude with prompt:',
prompt.substring(0, 50),
model ? `(model: ${model})` : ''
);
const args = [ const args = ['-p', '--verbose', '--dangerously-skip-permissions', '--output-format', 'stream-json'];
'-p',
'--verbose',
'--dangerously-skip-permissions',
'--output-format', 'stream-json',
];
if (model) { if (model) {
args.push('--model', model); args.push('--model', model);
} }
@@ -1517,7 +1582,10 @@ export class Session extends EventEmitter {
}); });
} catch (spawnErr) { } catch (spawnErr) {
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr); console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
this.emit('error', `Failed to spawn Claude: ${spawnErr instanceof Error ? spawnErr.message : String(spawnErr)}`); this.emit(
'error',
`Failed to spawn Claude: ${spawnErr instanceof Error ? spawnErr.message : String(spawnErr)}`
);
throw spawnErr; throw spawnErr;
} }
@@ -1561,7 +1629,7 @@ export class Session extends EventEmitter {
this.rejectPromise = null; this.rejectPromise = null;
// Find result from parsed messages or use text output // Find result from parsed messages or use text output
const resultMsg = this._messages.find(m => m.type === 'result'); const resultMsg = this._messages.find((m) => m.type === 'result');
if (resultMsg && !resultMsg.is_error) { if (resultMsg && !resultMsg.is_error) {
this._status = 'idle'; this._status = 'idle';
@@ -1579,13 +1647,15 @@ export class Session extends EventEmitter {
} else { } else {
this._status = 'idle'; this._status = 'idle';
if (resolve) { if (resolve) {
resolve({ result: this._textOutput.value || this._terminalBuffer.value, cost: this._totalCost }); resolve({
result: this._textOutput.value || this._terminalBuffer.value,
cost: this._totalCost,
});
} }
} }
this.emit('exit', exitCode); this.emit('exit', exitCode);
}); });
} catch (err) { } catch (err) {
this._status = 'error'; this._status = 'error';
reject(err); reject(err);
@@ -1649,8 +1719,8 @@ export class Session extends EventEmitter {
// Extract Claude session ID from messages (can be in any message type) // Extract Claude session ID from messages (can be in any message type)
// Support both sessionId (camelCase) and session_id (snake_case) // Support both sessionId (camelCase) and session_id (snake_case)
const msgSessionId = (msg as unknown as Record<string, unknown>).sessionId as string | undefined const msgSessionId =
?? msg.session_id; ((msg as unknown as Record<string, unknown>).sessionId as string | undefined) ?? msg.session_id;
if (msgSessionId && !this._claudeSessionId) { if (msgSessionId && !this._claudeSessionId) {
this._claudeSessionId = msgSessionId; this._claudeSessionId = msgSessionId;
} }
@@ -1689,7 +1759,10 @@ export class Session extends EventEmitter {
} }
} catch (parseErr) { } catch (parseErr) {
// Not JSON, just regular output - this is expected for non-JSON lines // 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); console.debug(
'[Session] Line not JSON (expected for text output):',
parseErr instanceof Error ? parseErr.message : parseErr
);
this._textOutput.append(line + '\n'); this._textOutput.append(line + '\n');
} }
} else if (trimmed) { } else if (trimmed) {
@@ -1829,7 +1902,9 @@ export class Session extends EventEmitter {
// Safety: Reject M values that would result in > 500k tokens // Safety: Reject M values that would result in > 500k tokens
// Claude's context window is ~200k, so anything claiming millions is likely a false match // Claude's context window is ~200k, so anything claiming millions is likely a false match
if (tokenCount > 0.5) { if (tokenCount > 0.5) {
console.warn(`[Session ${this.id}] Rejected suspicious M token value: ${tokenMatch[0]} (would be ${tokenCount * 1000000} tokens)`); console.warn(
`[Session ${this.id}] Rejected suspicious M token value: ${tokenMatch[0]} (would be ${tokenCount * 1000000} tokens)`
);
return; return;
} }
tokenCount *= 1000000; tokenCount *= 1000000;
@@ -1850,7 +1925,9 @@ export class Session extends EventEmitter {
// Safety: Reject suspiciously large jumps (max 100k per update) // Safety: Reject suspiciously large jumps (max 100k per update)
const MAX_DELTA_PER_UPDATE = 100_000; const MAX_DELTA_PER_UPDATE = 100_000;
if (delta > MAX_DELTA_PER_UPDATE) { if (delta > MAX_DELTA_PER_UPDATE) {
console.warn(`[Session ${this.id}] Rejected suspicious token jump: ${currentTotal} -> ${tokenCount} (delta: ${delta})`); console.warn(
`[Session ${this.id}] Rejected suspicious token jump: ${currentTotal} -> ${tokenCount} (delta: ${delta})`
);
return; return;
} }
@@ -1874,7 +1951,12 @@ export class Session extends EventEmitter {
if (this._cliInfoParsed) return; if (this._cliInfoParsed) return;
// Quick pre-checks // Quick pre-checks
if (!cleanData.includes('Claude') && !cleanData.includes('current:') && !cleanData.includes('Opus') && !cleanData.includes('Sonnet')) { if (
!cleanData.includes('Claude') &&
!cleanData.includes('current:') &&
!cleanData.includes('Opus') &&
!cleanData.includes('Sonnet')
) {
return; return;
} }
let changed = false; let changed = false;
@@ -1960,14 +2042,12 @@ export class Session extends EventEmitter {
if (this._isStopped) return; if (this._isStopped) return;
// Send /compact command with optional prompt // Send /compact command with optional prompt
const compactCmd = this._autoCompactPrompt const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r';
? `/compact ${this._autoCompactPrompt}\r`
: '/compact\r';
await this.writeViaMux(compactCmd); await this.writeViaMux(compactCmd);
this.emit('autoCompact', { this.emit('autoCompact', {
tokens: totalTokens, tokens: totalTokens,
threshold: this._autoCompactThreshold, threshold: this._autoCompactThreshold,
prompt: this._autoCompactPrompt || undefined prompt: this._autoCompactPrompt || undefined,
}); });
// Wait a moment then re-enable (longer than clear since compact takes time) // Wait a moment then re-enable (longer than clear since compact takes time)
@@ -2121,7 +2201,7 @@ export class Session extends EventEmitter {
async sendInput(input: string): Promise<void> { async sendInput(input: string): Promise<void> {
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 = err instanceof Error ? err.message : String(err);
this.emit('error', errorMsg); this.emit('error', errorMsg);
}); });
@@ -2260,7 +2340,7 @@ export class Session extends EventEmitter {
} }
// Give it a moment to terminate gracefully // Give it a moment to terminate gracefully
await new Promise(resolve => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS)); await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS));
// Force kill with SIGKILL if still alive // Force kill with SIGKILL if still alive
try { try {
+27 -10
View File
@@ -18,7 +18,16 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkS
import { writeFile, rename, unlink, copyFile, access } from 'node:fs/promises'; import { writeFile, rename, unlink, copyFile, access } from 'node:fs/promises';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { AppState, createInitialState, RalphSessionState, createInitialRalphSessionState, GlobalStats, createInitialGlobalStats, TokenStats, TokenUsageEntry } from './types.js'; import {
AppState,
createInitialState,
RalphSessionState,
createInitialRalphSessionState,
GlobalStats,
createInitialGlobalStats,
TokenStats,
TokenUsageEntry,
} from './types.js';
import { MAX_SESSION_TOKENS } from './utils/index.js'; import { MAX_SESSION_TOKENS } from './utils/index.js';
/** Debounce delay for batching state writes (ms) */ /** Debounce delay for batching state writes (ms) */
@@ -299,7 +308,11 @@ export class StateStore {
} 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++;
try { if (existsSync(tempPath)) unlinkSync(tempPath); } catch { /* ignore */ } try {
if (existsSync(tempPath)) unlinkSync(tempPath);
} catch {
/* ignore */
}
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) { if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
console.error('[StateStore] Circuit breaker OPEN - writes failing repeatedly'); console.error('[StateStore] Circuit breaker OPEN - writes failing repeatedly');
this.circuitBreakerOpen = true; this.circuitBreakerOpen = true;
@@ -384,7 +397,10 @@ export class StateStore {
* @param activeSessionIds - Set of currently active session IDs * @param activeSessionIds - Set of currently active session IDs
* @returns Number of sessions cleaned up * @returns Number of sessions cleaned up
*/ */
cleanupStaleSessions(activeSessionIds: Set<string>): { count: number; cleaned: Array<{ id: string; name?: string }> } { cleanupStaleSessions(activeSessionIds: Set<string>): {
count: number;
cleaned: Array<{ id: string; name?: string }>;
} {
const allSessionIds = Object.keys(this.state.sessions); const allSessionIds = Object.keys(this.state.sessions);
const cleaned: Array<{ id: string; name?: string }> = []; const cleaned: Array<{ id: string; name?: string }> = [];
@@ -481,7 +497,9 @@ export class StateStore {
} }
// Reject negative values // Reject negative values
if (inputTokens < 0 || outputTokens < 0 || cost < 0) { if (inputTokens < 0 || outputTokens < 0 || cost < 0) {
console.warn(`[StateStore] Rejected negative global stats: input=${inputTokens}, output=${outputTokens}, cost=${cost}`); console.warn(
`[StateStore] Rejected negative global stats: input=${inputTokens}, output=${outputTokens}, cost=${cost}`
);
return; return;
} }
@@ -505,7 +523,9 @@ export class StateStore {
* Returns aggregate stats combining global (deleted sessions) + active sessions. * Returns aggregate stats combining global (deleted sessions) + active sessions.
* @param activeSessions Map of active session states * @param activeSessions Map of active session states
*/ */
getAggregateStats(activeSessions: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }>): { getAggregateStats(
activeSessions: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }>
): {
totalInputTokens: number; totalInputTokens: number;
totalOutputTokens: number; totalOutputTokens: number;
totalCost: number; totalCost: number;
@@ -602,7 +622,7 @@ export class StateStore {
} }
// Find or create today's entry // Find or create today's entry
let todayEntry = stats.daily.find(e => e.date === today); let todayEntry = stats.daily.find((e) => e.date === today);
if (!todayEntry) { if (!todayEntry) {
todayEntry = { todayEntry = {
date: today, date: today,
@@ -617,10 +637,7 @@ export class StateStore {
// Accumulate tokens // Accumulate tokens
todayEntry.inputTokens += inputTokens; todayEntry.inputTokens += inputTokens;
todayEntry.outputTokens += outputTokens; todayEntry.outputTokens += outputTokens;
todayEntry.estimatedCost = this.calculateEstimatedCost( todayEntry.estimatedCost = this.calculateEstimatedCost(todayEntry.inputTokens, todayEntry.outputTokens);
todayEntry.inputTokens,
todayEntry.outputTokens
);
// Only increment session count for unique sessions // Only increment session count for unique sessions
if (sessionId && !this.dailySessionIds.has(sessionId)) { if (sessionId && !this.dailySessionIds.has(sessionId)) {
+96 -68
View File
@@ -77,16 +77,18 @@ export interface SubagentTranscriptEntry {
input_tokens?: number; input_tokens?: number;
output_tokens?: number; output_tokens?: number;
}; };
content: string | Array<{ content:
type: 'text' | 'tool_use' | 'tool_result'; | string
text?: string; | Array<{
name?: string; type: 'text' | 'tool_use' | 'tool_result';
id?: string; // tool_use_id for tool_use blocks text?: string;
tool_use_id?: string; // tool_use_id for tool_result blocks name?: string;
input?: Record<string, unknown>; id?: string; // tool_use_id for tool_use blocks
content?: string | Array<{ type: string; text?: string }>; // tool_result content tool_use_id?: string; // tool_use_id for tool_result blocks
is_error?: boolean; // For tool_result errors input?: Record<string, unknown>;
}>; content?: string | Array<{ type: string; text?: string }>; // tool_result content
is_error?: boolean; // For tool_result errors
}>;
}; };
data?: { data?: {
type: string; type: string;
@@ -135,11 +137,11 @@ const MAX_TRACKED_AGENTS = 500; // Maximum agents to track (LRU eviction when ex
// Internal Claude Code agent patterns to filter out (not real user-initiated subagents) // Internal Claude Code agent patterns to filter out (not real user-initiated subagents)
const INTERNAL_AGENT_PATTERNS = [ const INTERNAL_AGENT_PATTERNS = [
/^\[?SUGGESTION MODE/i, // Claude Code's internal suggestion mode /^\[?SUGGESTION MODE/i, // Claude Code's internal suggestion mode
/^Suggest what user might/i, // Suggestion mode prompt variant /^Suggest what user might/i, // Suggestion mode prompt variant
/aprompt/i, // Internal prompt agent (anywhere in string) /aprompt/i, // Internal prompt agent (anywhere in string)
/^a\s?prompt/i, // Variants of internal prompt agent /^a\s?prompt/i, // Variants of internal prompt agent
/^prompt$/i, // Just "prompt" /^prompt$/i, // Just "prompt"
]; ];
// Minimum description length - very short descriptions are likely internal or malformed // Minimum description length - very short descriptions are likely internal or malformed
@@ -194,7 +196,7 @@ export class SubagentWatcher extends EventEmitter {
if (!description) return false; if (!description) return false;
// Filter out very short descriptions (likely internal or malformed) // Filter out very short descriptions (likely internal or malformed)
if (description.length < MIN_DESCRIPTION_LENGTH) return true; if (description.length < MIN_DESCRIPTION_LENGTH) return true;
return INTERNAL_AGENT_PATTERNS.some(pattern => pattern.test(description)); return INTERNAL_AGENT_PATTERNS.some((pattern) => pattern.test(description));
} }
/** /**
@@ -263,7 +265,7 @@ export class SubagentWatcher extends EventEmitter {
if (await this.checkSubagentFileAlive(info)) continue; if (await this.checkSubagentFileAlive(info)) continue;
// Tier 2: Cached PID check (~0.1ms per agent) // Tier 2: Cached PID check (~0.1ms per agent)
if (info.pid && await this.checkPidAlive(info.pid)) continue; if (info.pid && (await this.checkPidAlive(info.pid))) continue;
// Tiers 1+2 failed — need expensive scan for this agent // Tiers 1+2 failed — need expensive scan for this agent
needsFullScan.push(info); needsFullScan.push(info);
@@ -322,20 +324,35 @@ export class SubagentWatcher extends EventEmitter {
resolve(stdout); resolve(stdout);
}); });
}); });
const pids = pgrepOutput.trim().split('\n').filter(Boolean).map(s => parseInt(s, 10)).filter(n => !Number.isNaN(n)); const pids = pgrepOutput
.trim()
.split('\n')
.filter(Boolean)
.map((s) => parseInt(s, 10))
.filter((n) => !Number.isNaN(n));
// Read /proc for all PIDs in parallel // Read /proc for all PIDs in parallel
await Promise.all(pids.map(async (pid) => { await Promise.all(
let environ = ''; pids.map(async (pid) => {
let cmdline = ''; let environ = '';
try { environ = await readFile(`/proc/${pid}/environ`, 'utf8'); } catch { /* skip */ } let cmdline = '';
try { cmdline = await readFile(`/proc/${pid}/cmdline`, 'utf8'); } catch { /* skip */ } try {
// Skip main Codeman-managed Claude processes — only track subagents environ = await readFile(`/proc/${pid}/environ`, 'utf8');
if (environ.includes('CODEMAN_MUX=1')) return; } catch {
if (environ || cmdline) { /* skip */
result.set(pid, { environ, cmdline }); }
} try {
})); cmdline = await readFile(`/proc/${pid}/cmdline`, 'utf8');
} catch {
/* skip */
}
// Skip main Codeman-managed Claude processes — only track subagents
if (environ.includes('CODEMAN_MUX=1')) return;
if (environ || cmdline) {
result.set(pid, { environ, cmdline });
}
})
);
// Update cached PIDs on tracked agents // Update cached PIDs on tracked agents
for (const [pid, procInfo] of result) { for (const [pid, procInfo] of result) {
@@ -567,9 +584,7 @@ export class SubagentWatcher extends EventEmitter {
*/ */
getSubagentsForSession(workingDir: string): SubagentInfo[] { getSubagentsForSession(workingDir: string): SubagentInfo[] {
const projectHash = this.getProjectHash(workingDir); const projectHash = this.getProjectHash(workingDir);
return Array.from(this.agentInfo.values()).filter( return Array.from(this.agentInfo.values()).filter((info) => info.projectHash === projectHash);
(info) => info.projectHash === projectHash
);
} }
/** /**
@@ -815,7 +830,8 @@ export class SubagentWatcher extends EventEmitter {
} else if (content.type === 'text' && content.text) { } else if (content.type === 'text' && content.text) {
const text = content.text.trim(); const text = content.text.trim();
if (text.length > 0) { if (text.length > 0) {
const preview = text.length > TEXT_PREVIEW_LENGTH ? text.substring(0, TEXT_PREVIEW_LENGTH) + '...' : text; const preview =
text.length > TEXT_PREVIEW_LENGTH ? text.substring(0, TEXT_PREVIEW_LENGTH) + '...' : text;
lines.push(`${this.formatTime(entry.timestamp)} 💬 ${preview.replace(/\n/g, ' ')}`); lines.push(`${this.formatTime(entry.timestamp)} 💬 ${preview.replace(/\n/g, ' ')}`);
} }
} }
@@ -860,7 +876,7 @@ export class SubagentWatcher extends EventEmitter {
const MAX_LEN = SMART_TITLE_MAX_LENGTH; const MAX_LEN = SMART_TITLE_MAX_LENGTH;
// Get first line/sentence // Get first line/sentence
let title = text.split('\n')[0].trim(); const title = text.split('\n')[0].trim();
// If already short enough, use it // If already short enough, use it
if (title.length <= MAX_LEN) { if (title.length <= MAX_LEN) {
@@ -932,14 +948,18 @@ export class SubagentWatcher extends EventEmitter {
// Check cache first (covers burst of simultaneous agent discoveries) // Check cache first (covers burst of simultaneous agent discoveries)
const cached = this.parentDescriptionCache.get(cacheKey); const cached = this.parentDescriptionCache.get(cacheKey);
if (cached && (Date.now() - cached.timestamp) < CACHE_TTL_MS) { if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
return cached.descriptions.get(agentId); return cached.descriptions.get(agentId);
} }
try { try {
// The parent session's transcript is at: ~/.claude/projects/{projectHash}/{sessionId}.jsonl // The parent session's transcript is at: ~/.claude/projects/{projectHash}/{sessionId}.jsonl
const transcriptPath = join(CLAUDE_PROJECTS_DIR, projectHash, `${sessionId}.jsonl`); const transcriptPath = join(CLAUDE_PROJECTS_DIR, projectHash, `${sessionId}.jsonl`);
try { await statAsync(transcriptPath); } catch { return undefined; } try {
await statAsync(transcriptPath);
} catch {
return undefined;
}
const content = await readFile(transcriptPath, 'utf8'); const content = await readFile(transcriptPath, 'utf8');
const lines = content.split('\n').filter((l) => l.trim()); const lines = content.split('\n').filter((l) => l.trim());
@@ -949,11 +969,7 @@ export class SubagentWatcher extends EventEmitter {
for (const line of lines) { for (const line of lines) {
try { try {
const entry = JSON.parse(line); const entry = JSON.parse(line);
if ( if (entry.type === 'user' && entry.toolUseResult?.agentId && entry.toolUseResult?.description) {
entry.type === 'user' &&
entry.toolUseResult?.agentId &&
entry.toolUseResult?.description
) {
descriptions.set(entry.toolUseResult.agentId, entry.toolUseResult.description); descriptions.set(entry.toolUseResult.agentId, entry.toolUseResult.description);
} }
} catch { } catch {
@@ -982,7 +998,11 @@ export class SubagentWatcher extends EventEmitter {
let lineCount = 0; let lineCount = 0;
let resolved = false; let resolved = false;
rl.on('line', (line) => { rl.on('line', (line) => {
if (resolved || lineCount >= 5) { rl.close(); stream.destroy(); return; } if (resolved || lineCount >= 5) {
rl.close();
stream.destroy();
return;
}
lineCount++; lineCount++;
if (!line.trim()) return; if (!line.trim()) return;
try { try {
@@ -1008,8 +1028,12 @@ export class SubagentWatcher extends EventEmitter {
// Skip malformed lines // Skip malformed lines
} }
}); });
rl.on('close', () => { if (!resolved) resolve(undefined); }); rl.on('close', () => {
rl.on('error', () => { if (!resolved) resolve(undefined); }); if (!resolved) resolve(undefined);
});
rl.on('error', () => {
if (!resolved) resolve(undefined);
});
}); });
} catch { } catch {
// Failed to read file // Failed to read file
@@ -1021,7 +1045,11 @@ export class SubagentWatcher extends EventEmitter {
* Scan for all subagent directories (async to avoid blocking event loop) * Scan for all subagent directories (async to avoid blocking event loop)
*/ */
private async scanForSubagents(): Promise<void> { private async scanForSubagents(): Promise<void> {
try { await statAsync(CLAUDE_PROJECTS_DIR); } catch { return; } try {
await statAsync(CLAUDE_PROJECTS_DIR);
} catch {
return;
}
try { try {
const projects = await readdir(CLAUDE_PROJECTS_DIR); const projects = await readdir(CLAUDE_PROJECTS_DIR);
@@ -1123,7 +1151,12 @@ export class SubagentWatcher extends EventEmitter {
* @param sessionId Claude session ID * @param sessionId Claude session ID
* @param isInitialScan If true, skip files older than STARTUP_MAX_FILE_AGE_MS * @param isInitialScan If true, skip files older than STARTUP_MAX_FILE_AGE_MS
*/ */
private async watchAgentFile(filePath: string, projectHash: string, sessionId: string, isInitialScan: boolean = false): Promise<void> { private async watchAgentFile(
filePath: string,
projectHash: string,
sessionId: string,
isInitialScan: boolean = false
): Promise<void> {
if (this.fileWatchers.has(filePath)) return; if (this.fileWatchers.has(filePath)) return;
const agentId = basename(filePath).replace('agent-', '').replace('.jsonl', ''); const agentId = basename(filePath).replace('agent-', '').replace('.jsonl', '');
@@ -1192,12 +1225,14 @@ export class SubagentWatcher extends EventEmitter {
this.emit('subagent:discovered', info); this.emit('subagent:discovered', info);
// Read existing content // Read existing content
this.tailFile(filePath, agentId, sessionId, 0).then((position) => { this.tailFile(filePath, agentId, sessionId, 0)
this.filePositions.set(filePath, position); .then((position) => {
}).catch((err) => { this.filePositions.set(filePath, position);
// Log but don't throw - non-critical background operation })
console.warn(`[SubagentWatcher] Failed to read initial content for ${agentId}:`, err); .catch((err) => {
}); // Log but don't throw - non-critical background operation
console.warn(`[SubagentWatcher] Failed to read initial content for ${agentId}:`, err);
});
// Watch for changes // Watch for changes
try { try {
@@ -1269,12 +1304,7 @@ export class SubagentWatcher extends EventEmitter {
/** /**
* Tail a file from a specific position * Tail a file from a specific position
*/ */
private async tailFile( private async tailFile(filePath: string, agentId: string, sessionId: string, fromPosition: number): Promise<number> {
filePath: string,
agentId: string,
sessionId: string,
fromPosition: number
): Promise<number> {
return new Promise((resolve) => { return new Promise((resolve) => {
let position = fromPosition; let position = fromPosition;
@@ -1337,11 +1367,7 @@ export class SubagentWatcher extends EventEmitter {
// Check if this is first user message and description is missing // Check if this is first user message and description is missing
if (info && !info.description && entry.type === 'user' && entry.message?.content) { if (info && !info.description && entry.type === 'user' && entry.message?.content) {
// First try parent transcript (most reliable) // First try parent transcript (most reliable)
let description = await this.extractDescriptionFromParentTranscript( let description = await this.extractDescriptionFromParentTranscript(info.projectHash, info.sessionId, agentId);
info.projectHash,
info.sessionId,
agentId
);
// Fallback: extract smart title from the prompt content // Fallback: extract smart title from the prompt content
if (!description) { if (!description) {
let text: string | undefined; let text: string | undefined;
@@ -1378,9 +1404,11 @@ export class SubagentWatcher extends EventEmitter {
resultCount: entry.data.resultCount, resultCount: entry.data.resultCount,
// Extract hook event info if present // Extract hook event info if present
hookEvent: entry.data.hookEvent, hookEvent: entry.data.hookEvent,
hookName: entry.data.hookName || (entry.data.hookEvent && entry.data.tool_name hookName:
? `${entry.data.hookEvent}:${entry.data.tool_name}` entry.data.hookName ||
: undefined), (entry.data.hookEvent && entry.data.tool_name
? `${entry.data.hookEvent}:${entry.data.tool_name}`
: undefined),
}; };
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) {
@@ -1531,8 +1559,8 @@ export class SubagentWatcher extends EventEmitter {
if (typeof content === 'string') return content; if (typeof content === 'string') return content;
if (Array.isArray(content)) { if (Array.isArray(content)) {
return content return content
.filter(c => c.type === 'text' && c.text) .filter((c) => c.type === 'text' && c.text)
.map(c => c.text) .map((c) => c.text)
.join('\n'); .join('\n');
} }
return ''; return '';
+15 -7
View File
@@ -196,7 +196,9 @@ export class TaskQueue extends EventEmitter {
private validateDependencies(taskId: string, dependencies: string[]): void { private validateDependencies(taskId: string, dependencies: string[]): void {
for (const depId of dependencies) { for (const depId of dependencies) {
if (this.wouldCreateCycle(taskId, depId)) { if (this.wouldCreateCycle(taskId, depId)) {
throw new Error(`Circular dependency detected: adding dependency ${depId} to task ${taskId} would create a cycle`); throw new Error(
`Circular dependency detected: adding dependency ${depId} to task ${taskId} would create a cycle`
);
} }
} }
} }
@@ -208,14 +210,21 @@ export class TaskQueue extends EventEmitter {
/** Gets the currently running task for a session, if any. */ /** Gets the currently running task for a session, if any. */
getRunningTaskForSession(sessionId: string): Task | null { getRunningTaskForSession(sessionId: string): Task | null {
return this.getAllTasks().find( return this.getAllTasks().find((t) => t.isRunning() && t.assignedSessionId === sessionId) || null;
(t) => t.isRunning() && t.assignedSessionId === sessionId
) || null;
} }
/** Gets counts of tasks by status (single-pass). */ /** Gets counts of tasks by status (single-pass). */
getCount(): { total: number; pending: number; running: number; completed: number; failed: number } { getCount(): {
let pending = 0, running = 0, completed = 0, failed = 0; total: number;
pending: number;
running: number;
completed: number;
failed: number;
} {
let pending = 0,
running = 0,
completed = 0,
failed = 0;
for (const task of this.tasks.values()) { for (const task of this.tasks.values()) {
if (task.isPending()) pending++; if (task.isPending()) pending++;
else if (task.isRunning()) running++; else if (task.isRunning()) running++;
@@ -258,7 +267,6 @@ export class TaskQueue extends EventEmitter {
this.tasks.clear(); this.tasks.clear();
return count; return count;
} }
} }
// Singleton instance // Singleton instance
+25 -19
View File
@@ -51,21 +51,13 @@ const MAX_PENDING_TOOL_USES = 100;
* Used as fallback when JSON parsing doesn't capture the launch. * Used as fallback when JSON parsing doesn't capture the launch.
* Capture group 1: Agent/task type name * Capture group 1: Agent/task type name
*/ */
const LAUNCH_PATTERNS = [ const LAUNCH_PATTERNS = [/Launching\s+(\w+)\s+agent/i, /Starting\s+(\w+)\s+task/i, /Spawning\s+(\w+)\s+agent/i];
/Launching\s+(\w+)\s+agent/i,
/Starting\s+(\w+)\s+task/i,
/Spawning\s+(\w+)\s+agent/i,
];
/** /**
* Patterns that indicate a task has completed. * Patterns that indicate a task has completed.
* Used as fallback when JSON parsing doesn't capture the result. * Used as fallback when JSON parsing doesn't capture the result.
*/ */
const COMPLETE_PATTERNS = [ const COMPLETE_PATTERNS = [/Task\s+completed/i, /Agent\s+finished/i, /Background\s+task\s+done/i];
/Task\s+completed/i,
/Agent\s+finished/i,
/Background\s+task\s+done/i,
];
// ========== Type Definitions ========== // ========== Type Definitions ==========
@@ -218,7 +210,10 @@ export class TaskTracker extends EventEmitter {
private taskStack: string[] = []; private taskStack: string[] = [];
/** Pending tool_use blocks waiting for results (with timestamp for cleanup) */ /** Pending tool_use blocks waiting for results (with timestamp for cleanup) */
private pendingToolUses: Map<string, { description: string; subagentType: string; parentId: string | null; createdAt: number }> = new Map(); private pendingToolUses: Map<
string,
{ description: string; subagentType: string; parentId: string | null; createdAt: number }
> = new Map();
/** /**
* Creates a new TaskTracker instance. * Creates a new TaskTracker instance.
@@ -316,7 +311,12 @@ export class TaskTracker extends EventEmitter {
const parentId = this.taskStack.length > 0 ? this.taskStack[this.taskStack.length - 1] : null; const parentId = this.taskStack.length > 0 ? this.taskStack[this.taskStack.length - 1] : null;
// Store pending tool use - task starts when we see activity // Store pending tool use - task starts when we see activity
this.pendingToolUses.set(toolUseId, { description, subagentType, parentId, createdAt: Date.now() }); this.pendingToolUses.set(toolUseId, {
description,
subagentType,
parentId,
createdAt: Date.now(),
});
// Clean up old pending entries to prevent unbounded growth // Clean up old pending entries to prevent unbounded growth
this.cleanupOldPendingToolUses(); this.cleanupOldPendingToolUses();
@@ -361,9 +361,7 @@ export class TaskTracker extends EventEmitter {
if (task) { if (task) {
task.status = block.is_error ? 'failed' : 'completed'; task.status = block.is_error ? 'failed' : 'completed';
task.endTime = Date.now(); task.endTime = Date.now();
task.output = typeof block.content === 'string' task.output = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
? block.content
: JSON.stringify(block.content);
// Remove from stack // Remove from stack
const stackIndex = this.taskStack.indexOf(toolUseId); const stackIndex = this.taskStack.indexOf(toolUseId);
@@ -610,13 +608,21 @@ export class TaskTracker extends EventEmitter {
* - failed: Tasks that ended with errors * - failed: Tasks that ended with errors
*/ */
getStats(): { total: number; running: number; completed: number; failed: number } { getStats(): { total: number; running: number; completed: number; failed: number } {
let running = 0, completed = 0, failed = 0; let running = 0,
completed = 0,
failed = 0;
for (const task of this.tasks.values()) { for (const task of this.tasks.values()) {
switch (task.status) { switch (task.status) {
case 'running': running++; break; case 'running':
case 'completed': completed++; break; running++;
case 'failed': failed++; break; break;
case 'completed':
completed++;
break;
case 'failed':
failed++;
break;
default: default:
assertNever(task.status, `Unhandled BackgroundTask status: ${task.status}`); assertNever(task.status, `Unhandled BackgroundTask status: ${task.status}`);
} }
+12 -16
View File
@@ -86,13 +86,13 @@ export class TeamWatcher extends EventEmitter {
getTeamTasks(teamName: string): TeamTask[] { getTeamTasks(teamName: string): TeamTask[] {
const tasks = this.teamTasks.get(teamName); const tasks = this.teamTasks.get(teamName);
if (!tasks) return []; if (!tasks) return [];
return tasks.filter(t => !t.metadata?._internal); return tasks.filter((t) => !t.metadata?._internal);
} }
/** Count active (non-completed) tasks for a team */ /** Count active (non-completed) tasks for a team */
getActiveTaskCount(teamName: string): number { getActiveTaskCount(teamName: string): number {
const tasks = this.getTeamTasks(teamName); const tasks = this.getTeamTasks(teamName);
return tasks.filter(t => t.status !== 'completed').length; return tasks.filter((t) => t.status !== 'completed').length;
} }
/** Get inbox messages for a team member (or all members) */ /** Get inbox messages for a team member (or all members) */
@@ -108,9 +108,7 @@ export class TeamWatcher extends EventEmitter {
messages.push(...msgs); messages.push(...msgs);
} }
} }
return messages.sort((a, b) => return messages.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
} }
/** Check if a session has active teammates (for idle detection) */ /** Check if a session has active teammates (for idle detection) */
@@ -119,7 +117,7 @@ export class TeamWatcher extends EventEmitter {
if (!team) return false; if (!team) return false;
// Check if any non-lead members exist (they are active by definition while present) // Check if any non-lead members exist (they are active by definition while present)
const teammates = team.members.filter(m => m.agentType !== 'team-lead'); const teammates = team.members.filter((m) => m.agentType !== 'team-lead');
if (teammates.length === 0) return false; if (teammates.length === 0) return false;
// Check if team has active (non-completed) tasks // Check if team has active (non-completed) tasks
@@ -132,19 +130,17 @@ export class TeamWatcher extends EventEmitter {
const team = this.teams.get(teamName); const team = this.teams.get(teamName);
if (!team) return []; if (!team) return [];
return team.members return team.members.filter(
.filter((m): m is TeamMember & { tmuxPaneId: string } => (m): m is TeamMember & { tmuxPaneId: string } =>
m.agentType !== 'team-lead' && m.agentType !== 'team-lead' && !!m.tmuxPaneId && m.tmuxPaneId !== 'in-process'
!!m.tmuxPaneId && );
m.tmuxPaneId !== 'in-process'
);
} }
/** Get count of active teammates for a session */ /** Get count of active teammates for a session */
getActiveTeammateCount(sessionId: string): number { getActiveTeammateCount(sessionId: string): number {
const team = this.getTeamForSession(sessionId); const team = this.getTeamForSession(sessionId);
if (!team) return 0; if (!team) return 0;
return team.members.filter(m => m.agentType !== 'team-lead').length; return team.members.filter((m) => m.agentType !== 'team-lead').length;
} }
// ========== Private Methods ========== // ========== Private Methods ==========
@@ -257,7 +253,7 @@ export class TeamWatcher extends EventEmitter {
let taskFiles: string[]; let taskFiles: string[];
try { try {
taskFiles = (await readdir(teamTaskDir)).filter(f => f.endsWith('.json') && f !== '.lock'); taskFiles = (await readdir(teamTaskDir)).filter((f) => f.endsWith('.json') && f !== '.lock');
} catch { } catch {
continue; continue;
} }
@@ -285,7 +281,7 @@ export class TeamWatcher extends EventEmitter {
let inboxFiles: string[]; let inboxFiles: string[];
try { try {
inboxFiles = (await readdir(inboxDir)).filter(f => f.endsWith('.json')); inboxFiles = (await readdir(inboxDir)).filter((f) => f.endsWith('.json'));
} catch { } catch {
continue; continue;
} }
@@ -314,7 +310,7 @@ export class TeamWatcher extends EventEmitter {
this.inboxCache.set(cacheKey, messages); this.inboxCache.set(cacheKey, messages);
// Emit new messages — compare by timestamp to handle deletions/reordering // Emit new messages — compare by timestamp to handle deletions/reordering
const prevTimestamps = new Set(previous?.map(m => m.timestamp) || []); const prevTimestamps = new Set(previous?.map((m) => m.timestamp) || []);
for (const msg of messages) { for (const msg of messages) {
if (!prevTimestamps.has(msg.timestamp)) { if (!prevTimestamps.has(msg.timestamp)) {
this.emit('inboxMessage', { teamName, member: memberName, message: msg }); this.emit('inboxMessage', { teamName, member: memberName, message: msg });
+215 -151
View File
@@ -30,10 +30,25 @@ import { existsSync, readFileSync, mkdirSync } from 'node:fs';
import { writeFile, rename } from 'node:fs/promises'; import { writeFile, rename } from 'node:fs/promises';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { ProcessStats, PersistedRespawnConfig, getErrorMessage, DEFAULT_NICE_CONFIG, type PaneInfo, type ClaudeMode, type SessionMode, type OpenCodeConfig } from './types.js'; import {
ProcessStats,
PersistedRespawnConfig,
getErrorMessage,
DEFAULT_NICE_CONFIG,
type PaneInfo,
type ClaudeMode,
type SessionMode,
type OpenCodeConfig,
} from './types.js';
import { wrapWithNice } from './utils/nice-wrapper.js'; import { wrapWithNice } from './utils/nice-wrapper.js';
import { SAFE_PATH_PATTERN } from './utils/regex-patterns.js'; import { SAFE_PATH_PATTERN } from './utils/regex-patterns.js';
import type { TerminalMultiplexer, MuxSession, MuxSessionWithStats, CreateSessionOptions, RespawnPaneOptions } from './mux-interface.js'; import type {
TerminalMultiplexer,
MuxSession,
MuxSessionWithStats,
CreateSessionOptions,
RespawnPaneOptions,
} from './mux-interface.js';
// Claude CLI PATH resolution — shared utility // Claude CLI PATH resolution — shared utility
import { findClaudeDir } from './utils/claude-cli-resolver.js'; import { findClaudeDir } from './utils/claude-cli-resolver.js';
@@ -103,11 +118,23 @@ 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 (path.includes(';') || path.includes('&') || path.includes('|') || if (
path.includes('$') || path.includes('`') || path.includes('(') || path.includes(';') ||
path.includes(')') || path.includes('{') || path.includes('}') || path.includes('&') ||
path.includes('<') || path.includes('>') || path.includes("'") || path.includes('|') ||
path.includes('"') || path.includes('\n') || path.includes('\r')) { 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('..')) {
@@ -177,7 +204,7 @@ function buildSpawnCommand(options: {
}): string { }): string {
if (options.mode === 'claude') { if (options.mode === 'claude') {
// Validate model to prevent command injection // Validate model to prevent command injection
const safeModel = (options.model && /^[a-zA-Z0-9._-]+$/.test(options.model)) ? options.model : undefined; const safeModel = options.model && /^[a-zA-Z0-9._-]+$/.test(options.model) ? options.model : undefined;
const modelFlag = safeModel ? ` --model ${safeModel}` : ''; const modelFlag = safeModel ? ` --model ${safeModel}` : '';
return `claude${buildClaudePermissionFlags(options.claudeMode, options.allowedTools)} --session-id "${options.sessionId}"${modelFlag}`; return `claude${buildClaudePermissionFlags(options.claudeMode, options.allowedTools)} --session-id "${options.sessionId}"${modelFlag}`;
} }
@@ -204,7 +231,9 @@ function setOpenCodeEnvVars(muxName: string): void {
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
}); });
} catch { /* Non-critical — key may not be needed */ } } catch {
/* Non-critical — key may not be needed */
}
} }
} }
} }
@@ -225,7 +254,9 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi
const existing = JSON.parse(config.configContent) as Record<string, unknown>; const existing = JSON.parse(config.configContent) as Record<string, unknown>;
Object.assign(permConfig, existing); Object.assign(permConfig, existing);
permConfig.permission = { '*': 'allow' }; permConfig.permission = { '*': 'allow' };
} catch { /* invalid JSON, use default permConfig */ } } catch {
/* invalid JSON, use default permConfig */
}
} }
jsonContent = JSON.stringify(permConfig); jsonContent = JSON.stringify(permConfig);
} else if (config.configContent) { } else if (config.configContent) {
@@ -247,7 +278,9 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
}); });
} catch { /* Non-critical */ } } catch {
/* Non-critical */
}
} }
} }
@@ -394,7 +427,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE'); if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE');
const envExportsStr = envExports.join(' && '); const envExportsStr = envExports.join(' && ');
const baseCmd = buildSpawnCommand({ mode, sessionId, model, claudeMode, allowedTools, openCodeConfig }); const baseCmd = buildSpawnCommand({
mode,
sessionId,
model,
claudeMode,
allowedTools,
openCodeConfig,
});
const config = niceConfig || DEFAULT_NICE_CONFIG; const config = niceConfig || DEFAULT_NICE_CONFIG;
const cmd = wrapWithNice(baseCmd, config); const cmd = wrapWithNice(baseCmd, config);
@@ -412,15 +452,22 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// (Production uses systemd which has a clean env, but dev/test may be nested.) // (Production uses systemd which has a clean env, but dev/test may be nested.)
const cleanEnv = { ...process.env }; const cleanEnv = { ...process.env };
delete cleanEnv.TMUX; delete cleanEnv.TMUX;
execSync( execSync(`tmux new-session -ds "${muxName}" -c "${workingDir}" -x 120 -y 40`, {
`tmux new-session -ds "${muxName}" -c "${workingDir}" -x 120 -y 40`, cwd: workingDir,
{ cwd: workingDir, timeout: EXEC_TIMEOUT_MS, stdio: 'ignore', env: cleanEnv } timeout: EXEC_TIMEOUT_MS,
); stdio: 'ignore',
env: cleanEnv,
});
// Set remain-on-exit now that the server is running — must be before respawn-pane // Set remain-on-exit now that the server is running — must be before respawn-pane
try { try {
execSync(`tmux set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS, stdio: 'ignore' }); execSync(`tmux set-option -t "${muxName}" remain-on-exit on`, {
} catch { /* Non-critical */ } timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
});
} catch {
/* Non-critical */
}
// 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)
@@ -430,13 +477,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
// Replace the shell with the actual command (no echo in terminal) // Replace the shell with the actual command (no echo in terminal)
execSync( execSync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, timeout: EXEC_TIMEOUT_MS,
{ timeout: EXEC_TIMEOUT_MS, stdio: 'ignore' } stdio: 'ignore',
); });
// Wait for tmux session to be queryable // Wait for tmux session to be queryable
await new Promise(resolve => setTimeout(resolve, TMUX_CREATION_WAIT_MS)); await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
// Non-critical tmux config — run in parallel to avoid blocking event loop. // Non-critical tmux config — run in parallel to avoid blocking event loop.
// These configure UX niceties (no status bar, true color). // These configure UX niceties (no status bar, true color).
@@ -445,18 +492,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const configPromises: Promise<void>[] = [ const configPromises: Promise<void>[] = [
// Disable tmux status bar — Codeman's web UI provides session info // Disable tmux status bar — Codeman's web UI provides session info
execAsync(`tmux set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS }) execAsync(`tmux set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS })
.then(() => {}).catch(() => { /* Non-critical — session still works with status bar */ }), .then(() => {})
.catch(() => {
/* Non-critical — session still works with status bar */
}),
// Override global remain-on-exit with session-level setting // Override global remain-on-exit with session-level setting
execAsync(`tmux set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS }) execAsync(`tmux set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS })
.then(() => {}).catch(() => { /* Already set globally as fallback */ }), .then(() => {})
.catch(() => {
/* Already set globally as fallback */
}),
]; ];
// Enable 24-bit true color passthrough — server-wide, set once per lifetime // Enable 24-bit true color passthrough — server-wide, set once per lifetime
if (!this.trueColorConfigured) { if (!this.trueColorConfigured) {
configPromises.push( configPromises.push(
execAsync(`tmux set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS }) execAsync(`tmux set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS })
.then(() => { this.trueColorConfigured = true; }) .then(() => {
.catch(() => { /* Non-critical — colors limited to 256 */ }) this.trueColorConfigured = true;
})
.catch(() => {
/* Non-critical — colors limited to 256 */
})
); );
} }
@@ -467,7 +524,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Get the PID of the pane process (retry for tmux server cold-start) // Get the PID of the pane process (retry for tmux server cold-start)
let pid = this.getPanePid(muxName); let pid = this.getPanePid(muxName);
for (let i = 0; !pid && i < GET_PID_MAX_RETRIES; i++) { for (let i = 0; !pid && i < GET_PID_MAX_RETRIES; i++) {
await new Promise(resolve => setTimeout(resolve, GET_PID_RETRY_MS)); await new Promise((resolve) => setTimeout(resolve, GET_PID_RETRY_MS));
pid = this.getPanePid(muxName); pid = this.getPanePid(muxName);
} }
if (!pid) { if (!pid) {
@@ -507,10 +564,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
try { try {
const output = execSync( const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_pid}'`, {
`tmux display-message -t "${muxName}" -p '#{pane_pid}'`, encoding: 'utf-8',
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } timeout: EXEC_TIMEOUT_MS,
).trim(); }).trim();
const pid = parseInt(output, 10); const pid = parseInt(output, 10);
return Number.isNaN(pid) ? null : pid; return Number.isNaN(pid) ? null : pid;
} catch { } catch {
@@ -533,10 +590,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (IS_TEST_MODE) return false; if (IS_TEST_MODE) return false;
if (!isValidMuxName(muxName)) return false; if (!isValidMuxName(muxName)) return false;
try { try {
const output = execSync( const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_dead}'`, {
`tmux display-message -t "${muxName}" -p '#{pane_dead}'`, encoding: 'utf-8',
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } timeout: EXEC_TIMEOUT_MS,
).trim(); }).trim();
return output === '1'; return output === '1';
} catch { } catch {
return false; return false;
@@ -578,7 +635,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE'); if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE');
const envExportsStr = envExports.join(' && '); const envExportsStr = envExports.join(' && ');
const baseCmd = buildSpawnCommand({ mode, sessionId, model, claudeMode, allowedTools, openCodeConfig }); const baseCmd = buildSpawnCommand({
mode,
sessionId,
model,
claudeMode,
allowedTools,
openCodeConfig,
});
const config = niceConfig || DEFAULT_NICE_CONFIG; const config = niceConfig || DEFAULT_NICE_CONFIG;
const cmd = wrapWithNice(baseCmd, config); const cmd = wrapWithNice(baseCmd, config);
const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`; const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`;
@@ -590,12 +654,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
setOpenCodeConfigContent(muxName, openCodeConfig); setOpenCodeConfigContent(muxName, openCodeConfig);
} }
await execAsync( await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, timeout: EXEC_TIMEOUT_MS,
{ timeout: EXEC_TIMEOUT_MS } });
);
// Wait for the respawned process to start // Wait for the respawned process to start
await new Promise(resolve => setTimeout(resolve, TMUX_CREATION_WAIT_MS)); await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
const pid = this.getPanePid(muxName); const pid = this.getPanePid(muxName);
if (pid) session.pid = pid; if (pid) session.pid = pid;
return pid; return pid;
@@ -628,7 +691,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}).trim(); }).trim();
if (output) { if (output) {
for (const childPid of output.split('\n').map(p => parseInt(p, 10)).filter(p => !Number.isNaN(p))) { for (const childPid of output
.split('\n')
.map((p) => parseInt(p, 10))
.filter((p) => !Number.isNaN(p))) {
pids.push(childPid); pids.push(childPid);
pids.push(...this.getChildPids(childPid)); pids.push(...this.getChildPids(childPid));
} }
@@ -655,14 +721,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const checkInterval = 100; const checkInterval = 100;
while (Date.now() - startTime < maxWaitMs) { while (Date.now() - startTime < maxWaitMs) {
const aliveCount = pids.filter(pid => this.isProcessAlive(pid)).length; const aliveCount = pids.filter((pid) => this.isProcessAlive(pid)).length;
if (aliveCount === 0) { if (aliveCount === 0) {
return true; return true;
} }
await new Promise(resolve => setTimeout(resolve, checkInterval)); await new Promise((resolve) => setTimeout(resolve, checkInterval));
} }
const stillAlive = pids.filter(pid => this.isProcessAlive(pid)); const stillAlive = pids.filter((pid) => this.isProcessAlive(pid));
if (stillAlive.length > 0) { if (stillAlive.length > 0) {
console.warn(`[TmuxManager] ${stillAlive.length} processes still alive after kill: ${stillAlive.join(', ')}`); console.warn(`[TmuxManager] ${stillAlive.length} processes still alive after kill: ${stillAlive.join(', ')}`);
} }
@@ -717,7 +783,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
} }
await new Promise(resolve => setTimeout(resolve, TMUX_KILL_WAIT_MS)); await new Promise((resolve) => setTimeout(resolve, TMUX_KILL_WAIT_MS));
childPids = this.getChildPids(currentPid); childPids = this.getChildPids(currentPid);
for (const childPid of childPids) { for (const childPid of childPids) {
@@ -735,7 +801,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (this.isProcessAlive(currentPid)) { if (this.isProcessAlive(currentPid)) {
try { try {
process.kill(-currentPid, 'SIGTERM'); process.kill(-currentPid, 'SIGTERM');
await new Promise(resolve => setTimeout(resolve, GRACEFUL_SHUTDOWN_WAIT_MS)); await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_WAIT_MS));
if (this.isProcessAlive(currentPid)) { if (this.isProcessAlive(currentPid)) {
process.kill(-currentPid, 'SIGKILL'); process.kill(-currentPid, 'SIGKILL');
} }
@@ -829,10 +895,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Discover unknown codeman sessions // Discover unknown codeman sessions
try { try {
const output = execSync( const output = execSync("tmux list-sessions -F '#{session_name}' 2>/dev/null || true", {
"tmux list-sessions -F '#{session_name}' 2>/dev/null || true", encoding: 'utf-8',
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } timeout: EXEC_TIMEOUT_MS,
).trim(); }).trim();
for (const line of output.split('\n')) { for (const line of output.split('\n')) {
const sessionName = line.trim(); const sessionName = line.trim();
@@ -890,26 +956,26 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
try { try {
const psOutput = execSync( const psOutput = execSync(`ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`, {
`ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`, encoding: 'utf-8',
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } timeout: EXEC_TIMEOUT_MS,
).trim(); }).trim();
const [rss, cpu] = psOutput.split(/\s+/).map(x => parseFloat(x) || 0); const [rss, cpu] = psOutput.split(/\s+/).map((x) => parseFloat(x) || 0);
let childCount = 0; let childCount = 0;
try { try {
const childOutput = execSync( const childOutput = execSync(`pgrep -P ${session.pid} | wc -l`, {
`pgrep -P ${session.pid} | wc -l`, encoding: 'utf-8',
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } timeout: EXEC_TIMEOUT_MS,
).trim(); }).trim();
childCount = parseInt(childOutput, 10) || 0; childCount = parseInt(childOutput, 10) || 0;
} catch { } catch {
// No children or command failed // No children or command failed
} }
return { return {
memoryMB: Math.round(rss / 1024 * 10) / 10, memoryMB: Math.round((rss / 1024) * 10) / 10,
cpuPercent: Math.round(cpu * 10) / 10, cpuPercent: Math.round(cpu * 10) / 10,
childCount, childCount,
updatedAt: Date.now(), updatedAt: Date.now(),
@@ -921,7 +987,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
async getSessionsWithStats(): Promise<MuxSessionWithStats[]> { async getSessionsWithStats(): Promise<MuxSessionWithStats[]> {
if (IS_TEST_MODE) { if (IS_TEST_MODE) {
return Array.from(this.sessions.values()).map(s => ({ return Array.from(this.sessions.values()).map((s) => ({
...s, ...s,
stats: { memoryMB: 0, cpuPercent: 0, childCount: 0, updatedAt: Date.now() }, stats: { memoryMB: 0, cpuPercent: 0, childCount: 0, updatedAt: Date.now() },
})); }));
@@ -932,7 +998,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return []; return [];
} }
const sessionPids = sessions.map(s => s.pid); const sessionPids = sessions.map((s) => s.pid);
const statsMap = new Map<number, ProcessStats>(); const statsMap = new Map<number, ProcessStats>();
try { try {
@@ -941,7 +1007,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const pgrepOutput = execSync( const pgrepOutput = execSync(
`for p in ${sessionPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`, `for p in ${sessionPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}
).trim(); ).trim();
for (const line of pgrepOutput.split('\n')) { for (const line of pgrepOutput.split('\n')) {
@@ -950,8 +1019,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (!Number.isNaN(sessionPid)) { if (!Number.isNaN(sessionPid)) {
const children = (childrenStr || '') const children = (childrenStr || '')
.split(',') .split(',')
.map(s => parseInt(s.trim(), 10)) .map((s) => parseInt(s.trim(), 10))
.filter(n => !Number.isNaN(n) && n > 0); .filter((n) => !Number.isNaN(n) && n > 0);
descendantMap.set(sessionPid, children); descendantMap.set(sessionPid, children);
} }
} }
@@ -967,10 +1036,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Step 3: Single ps call // Step 3: Single ps call
const pidArray = Array.from(allPids); const pidArray = Array.from(allPids);
if (pidArray.length > 0) { if (pidArray.length > 0) {
const psOutput = execSync( const psOutput = execSync(`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`, {
`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`, encoding: 'utf-8',
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } timeout: EXEC_TIMEOUT_MS,
).trim(); }).trim();
const processStats = new Map<number, { rss: number; cpu: number }>(); const processStats = new Map<number, { rss: number; cpu: number }>();
for (const line of psOutput.split('\n')) { for (const line of psOutput.split('\n')) {
@@ -1002,7 +1071,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
statsMap.set(sessionPid, { statsMap.set(sessionPid, {
memoryMB: Math.round(totalRss / 1024 * 10) / 10, memoryMB: Math.round((totalRss / 1024) * 10) / 10,
cpuPercent: Math.round(totalCpu * 10) / 10, cpuPercent: Math.round(totalCpu * 10) / 10,
childCount: children.length, childCount: children.length,
updatedAt: Date.now(), updatedAt: Date.now(),
@@ -1011,7 +1080,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
} catch { } catch {
// Fall back to individual queries // Fall back to individual queries
const statsPromises = sessions.map(session => this.getProcessStats(session.sessionId)); const statsPromises = sessions.map((session) => this.getProcessStats(session.sessionId));
const results = await Promise.allSettled(statsPromises); const results = await Promise.allSettled(statsPromises);
return sessions.map((session, i) => ({ return sessions.map((session, i) => ({
...session, ...session,
@@ -1019,7 +1088,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
})); }));
} }
return sessions.map(session => ({ return sessions.map((session) => ({
...session, ...session,
stats: statsMap.get(session.pid) || undefined, stats: statsMap.get(session.pid) || undefined,
})); }));
@@ -1145,7 +1214,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
async sendInput(sessionId: string, input: string): Promise<boolean> { async sendInput(sessionId: string, input: string): Promise<boolean> {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) { if (!session) {
console.error(`[TmuxManager] sendInput failed: no session found for ${sessionId}. Known: ${Array.from(this.sessions.keys()).join(', ')}`); console.error(
`[TmuxManager] sendInput failed: no session found for ${sessionId}. Known: ${Array.from(this.sessions.keys()).join(', ')}`
);
return false; return false;
} }
@@ -1154,7 +1225,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return true; return true;
} }
console.log(`[TmuxManager] sendInput to ${session.muxName}, input length: ${input.length}, hasCarriageReturn: ${input.includes('\r')}`); console.log(
`[TmuxManager] sendInput to ${session.muxName}, input length: ${input.length}, hasCarriageReturn: ${input.includes('\r')}`
);
if (!isValidMuxName(session.muxName)) { if (!isValidMuxName(session.muxName)) {
console.error('[TmuxManager] Invalid session name in sendInput:', session.muxName); console.error('[TmuxManager] Invalid session name in sendInput:', session.muxName);
@@ -1170,27 +1243,23 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Ink (Claude CLI's terminal framework) needs them split — sending both in a // Ink (Claude CLI's terminal framework) needs them split — sending both in a
// single tmux invocation (via \;) causes Ink to interpret Enter as a newline // single tmux invocation (via \;) causes Ink to interpret Enter as a newline
// character in the input buffer rather than as form submission. // character in the input buffer rather than as form submission.
await execAsync( await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, timeout: EXEC_TIMEOUT_MS,
{ timeout: EXEC_TIMEOUT_MS } });
); await new Promise((resolve) => setTimeout(resolve, 50));
await new Promise(resolve => setTimeout(resolve, 50)); await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, {
await execAsync( timeout: EXEC_TIMEOUT_MS,
`tmux send-keys -t "${session.muxName}" Enter`, });
{ timeout: EXEC_TIMEOUT_MS }
);
} else if (textPart) { } else if (textPart) {
// Text only, no Enter // Text only, no Enter
await execAsync( await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, timeout: EXEC_TIMEOUT_MS,
{ timeout: EXEC_TIMEOUT_MS } });
);
} else if (hasCarriageReturn) { } else if (hasCarriageReturn) {
// Enter only // Enter only
await execAsync( await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, {
`tmux send-keys -t "${session.muxName}" Enter`, timeout: EXEC_TIMEOUT_MS,
{ timeout: EXEC_TIMEOUT_MS } });
);
} }
return true; return true;
@@ -1215,10 +1284,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
try { try {
execSync( execSync(`tmux set-option -t "${muxName}" mouse on`, {
`tmux set-option -t "${muxName}" mouse on`, encoding: 'utf-8',
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } timeout: EXEC_TIMEOUT_MS,
); });
console.log(`[TmuxManager] Mouse mode ON for ${muxName}`); console.log(`[TmuxManager] Mouse mode ON for ${muxName}`);
return true; return true;
} catch (err) { } catch (err) {
@@ -1239,10 +1308,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
try { try {
execSync( execSync(`tmux set-option -t "${muxName}" mouse off`, {
`tmux set-option -t "${muxName}" mouse off`, encoding: 'utf-8',
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } timeout: EXEC_TIMEOUT_MS,
); });
console.log(`[TmuxManager] Mouse mode OFF for ${muxName}`); console.log(`[TmuxManager] Mouse mode OFF for ${muxName}`);
return true; return true;
} catch (err) { } catch (err) {
@@ -1283,16 +1352,19 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
).trim(); ).trim();
return output.split('\n').map(line => { return output
const [paneId, indexStr, pidStr, widthStr, heightStr] = line.split(':'); .split('\n')
return { .map((line) => {
paneId, const [paneId, indexStr, pidStr, widthStr, heightStr] = line.split(':');
paneIndex: parseInt(indexStr, 10), return {
panePid: parseInt(pidStr, 10), paneId,
width: parseInt(widthStr, 10), paneIndex: parseInt(indexStr, 10),
height: parseInt(heightStr, 10), panePid: parseInt(pidStr, 10),
}; width: parseInt(widthStr, 10),
}).filter(p => !Number.isNaN(p.paneIndex)); height: parseInt(heightStr, 10),
};
})
.filter((p) => !Number.isNaN(p.paneIndex));
} catch { } catch {
return []; return [];
} }
@@ -1314,33 +1386,31 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
// Build target: sessionName.paneId (e.g., "codeman-abc12345.%1") // Build target: sessionName.paneId (e.g., "codeman-abc12345.%1")
const target = paneTarget.startsWith('%') const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
? `${muxName}.${paneTarget}`
: `${muxName}.%${paneTarget}`;
try { try {
const hasCarriageReturn = input.includes('\r'); const hasCarriageReturn = input.includes('\r');
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd(); const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
if (textPart && hasCarriageReturn) { if (textPart && hasCarriageReturn) {
execSync( execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, encoding: 'utf-8',
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } timeout: EXEC_TIMEOUT_MS,
); });
execSync( execSync(`tmux send-keys -t ${shellescape(target)} Enter`, {
`tmux send-keys -t ${shellescape(target)} Enter`, encoding: 'utf-8',
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } timeout: EXEC_TIMEOUT_MS,
); });
} else if (textPart) { } else if (textPart) {
execSync( execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, encoding: 'utf-8',
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } timeout: EXEC_TIMEOUT_MS,
); });
} else if (hasCarriageReturn) { } else if (hasCarriageReturn) {
execSync( execSync(`tmux send-keys -t ${shellescape(target)} Enter`, {
`tmux send-keys -t ${shellescape(target)} Enter`, encoding: 'utf-8',
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } timeout: EXEC_TIMEOUT_MS,
); });
} }
return true; return true;
@@ -1365,15 +1435,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return null; return null;
} }
const target = paneTarget.startsWith('%') const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
? `${muxName}.${paneTarget}`
: `${muxName}.%${paneTarget}`;
try { try {
return execSync( return execSync(`tmux capture-pane -p -e -t ${shellescape(target)} -S -5000`, {
`tmux capture-pane -p -e -t ${shellescape(target)} -S -5000`, encoding: 'utf-8',
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } timeout: EXEC_TIMEOUT_MS,
); });
} catch (err) { } catch (err) {
console.error('[TmuxManager] Failed to capture pane buffer:', err); console.error('[TmuxManager] Failed to capture pane buffer:', err);
return null; return null;
@@ -1399,15 +1467,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return false; return false;
} }
const target = paneTarget.startsWith('%') const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
? `${muxName}.${paneTarget}`
: `${muxName}.%${paneTarget}`;
try { try {
execSync( execSync(`tmux pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, {
`tmux pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, encoding: 'utf-8',
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } timeout: EXEC_TIMEOUT_MS,
); });
return true; return true;
} catch (err) { } catch (err) {
console.error('[TmuxManager] Failed to start pipe-pane:', err); console.error('[TmuxManager] Failed to start pipe-pane:', err);
@@ -1429,15 +1495,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return false; return false;
} }
const target = paneTarget.startsWith('%') const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
? `${muxName}.${paneTarget}`
: `${muxName}.%${paneTarget}`;
try { try {
execSync( execSync(`tmux pipe-pane -t ${shellescape(target)}`, {
`tmux pipe-pane -t ${shellescape(target)}`, encoding: 'utf-8',
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } timeout: EXEC_TIMEOUT_MS,
); });
return true; return true;
} catch (err) { } catch (err) {
console.error('[TmuxManager] Failed to stop pipe-pane:', err); console.error('[TmuxManager] Failed to stop pipe-pane:', err);
+8 -12
View File
@@ -86,12 +86,7 @@ const POLL_INTERVAL_MS = 1000;
const MAX_MESSAGE_LENGTH = 500; const MAX_MESSAGE_LENGTH = 500;
/** Patterns that indicate plan mode / approval prompt */ /** Patterns that indicate plan mode / approval prompt */
const PLAN_MODE_PATTERNS = [ const PLAN_MODE_PATTERNS = [/ExitPlanMode/i, /AskUserQuestion/i, /Ready for user approval/i, /approve.*plan/i];
/ExitPlanMode/i,
/AskUserQuestion/i,
/Ready for user approval/i,
/approve.*plan/i,
];
// ========== TranscriptWatcher Class ========== // ========== TranscriptWatcher Class ==========
@@ -410,12 +405,13 @@ export class TranscriptWatcher extends EventEmitter {
if (entry.type !== 'assistant' || !entry.message?.content) return; if (entry.type !== 'assistant' || !entry.message?.content) return;
const content = entry.message.content; const content = entry.message.content;
const textToCheck = typeof content === 'string' const textToCheck =
? content typeof content === 'string'
: content ? content
.filter((b): b is { type: 'text'; text: string } => b.type === 'text' && !!b.text) : content
.map(b => b.text) .filter((b): b is { type: 'text'; text: string } => b.type === 'text' && !!b.text)
.join(' '); .map((b) => b.text)
.join(' ');
// Also check for tool_use with ExitPlanMode or AskUserQuestion // Also check for tool_use with ExitPlanMode or AskUserQuestion
if (Array.isArray(content)) { if (Array.isArray(content)) {
+4 -1
View File
@@ -112,7 +112,10 @@ export class TunnelManager extends EventEmitter {
const binary = this.resolveCloudflared(); const binary = this.resolveCloudflared();
if (!binary) { if (!binary) {
this.emit('error', 'cloudflared not found. Install from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/'); this.emit(
'error',
'cloudflared not found. Install from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/'
);
return; return;
} }
+29 -31
View File
@@ -80,16 +80,16 @@ export type TddPhase = 'setup' | 'test' | 'impl' | 'verify' | 'review';
/** Types of session lifecycle events recorded to the audit log */ /** Types of session lifecycle events recorded to the audit log */
export type LifecycleEventType = export type LifecycleEventType =
| 'created' // Session object created | 'created' // Session object created
| 'started' // PTY process launched (interactive/shell/prompt) | 'started' // PTY process launched (interactive/shell/prompt)
| 'exit' // PTY process exited (with exit code) | 'exit' // PTY process exited (with exit code)
| 'deleted' // cleanupSession() called — session removed | 'deleted' // cleanupSession() called — session removed
| 'detached' // Server shutdown — PTY left alive in tmux for recovery | 'detached' // Server shutdown — PTY left alive in tmux for recovery
| 'recovered' // Session restored from tmux on server restart | 'recovered' // Session restored from tmux on server restart
| 'stale_cleaned' // Removed from state.json by cleanupStaleSessions() | 'stale_cleaned' // Removed from state.json by cleanupStaleSessions()
| 'mux_died' // tmux session died (detected by reconciliation) | 'mux_died' // tmux session died (detected by reconciliation)
| 'server_started' // Server started (marker for restart detection) | 'server_started' // Server started (marker for restart detection)
| 'server_stopped'; // Server shutting down | 'server_stopped'; // Server shutting down
/** A single entry in the session lifecycle audit log */ /** A single entry in the session lifecycle audit log */
export interface LifecycleEntry { export interface LifecycleEntry {
@@ -145,15 +145,7 @@ export interface SessionConfig {
/** /**
* Available session colors for visual differentiation * Available session colors for visual differentiation
*/ */
export type SessionColor = export type SessionColor = 'default' | 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' | 'pink';
| 'default'
| 'red'
| 'orange'
| 'yellow'
| 'green'
| 'blue'
| 'purple'
| 'pink';
/** /**
* Current state of a session * Current state of a session
@@ -472,11 +464,11 @@ export interface RespawnConfig {
* Outcome of a respawn cycle * Outcome of a respawn cycle
*/ */
export type CycleOutcome = export type CycleOutcome =
| 'success' // Cycle completed normally | 'success' // Cycle completed normally
| 'stuck_recovery' // Stuck-state recovery triggered | 'stuck_recovery' // Stuck-state recovery triggered
| 'blocked' // Blocked by circuit breaker or exit signal | 'blocked' // Blocked by circuit breaker or exit signal
| 'error' // Error during cycle | 'error' // Error during cycle
| 'cancelled'; // Cancelled (e.g., controller stopped) | 'cancelled'; // Cancelled (e.g., controller stopped)
/** /**
* Metrics for a single respawn cycle. * Metrics for a single respawn cycle.
@@ -687,7 +679,13 @@ export const ErrorMessages: Record<ApiErrorCode, string> = {
/** /**
* Hook event types triggered by Claude Code's hooks system * Hook event types triggered by Claude Code's hooks system
*/ */
export type HookEventType = 'idle_prompt' | 'permission_prompt' | 'elicitation_dialog' | 'stop' | 'teammate_idle' | 'task_completed'; export type HookEventType =
| 'idle_prompt'
| 'permission_prompt'
| 'elicitation_dialog'
| 'stop'
| 'teammate_idle'
| 'task_completed';
// ========== API Response Types ========== // ========== API Response Types ==========
@@ -842,12 +840,12 @@ export const DEFAULT_CONFIG: AppConfig = {
maxConcurrentSessions: 5, maxConcurrentSessions: 5,
stateFilePath: '', stateFilePath: '',
respawn: { respawn: {
idleTimeoutMs: 5000, // 5 seconds of no activity after prompt idleTimeoutMs: 5000, // 5 seconds of no activity after prompt
updatePrompt: 'update all the docs and CLAUDE.md', updatePrompt: 'update all the docs and CLAUDE.md',
interStepDelayMs: 1000, // 1 second between steps interStepDelayMs: 1000, // 1 second between steps
enabled: false, // disabled by default enabled: false, // disabled by default
sendClear: true, // send /clear after update prompt sendClear: true, // send /clear after update prompt
sendInit: true, // send /init after /clear sendInit: true, // send /init after /clear
}, },
lastUsedCase: null, lastUsedCase: null,
ralphEnabled: false, ralphEnabled: false,
@@ -1117,7 +1115,7 @@ export function createInitialCircuitBreakerStatus(): CircuitBreakerStatus {
*/ */
export function createInitialRalphTrackerState(): RalphTrackerState { export function createInitialRalphTrackerState(): RalphTrackerState {
return { return {
enabled: false, // Disabled by default, auto-enables when Ralph patterns detected enabled: false, // Disabled by default, auto-enables when Ralph patterns detected
active: false, active: false,
completionPhrase: null, completionPhrase: null,
startedAt: null, startedAt: null,
+1 -1
View File
@@ -149,7 +149,7 @@ export class BufferAccumulator {
* @returns True if buffer ends with the suffix * @returns True if buffer ends with the suffix
*/ */
endsWith(suffix: string): boolean { endsWith(suffix: string): boolean {
if (!suffix) return true; // All strings end with empty string if (!suffix) return true; // All strings end with empty string
if (suffix.length > this.totalLength) return false; if (suffix.length > this.totalLength) return false;
return this.tail(suffix.length) === suffix; return this.tail(suffix.length) === suffix;
} }
+1 -1
View File
@@ -57,7 +57,7 @@ export function findClaudeDir(): string | null {
} }
} }
_claudeDir = ''; // mark as searched, not found _claudeDir = ''; // mark as searched, not found
return null; return null;
} }
+37 -45
View File
@@ -115,11 +115,7 @@ export class CleanupManager implements Disposable {
* @param options - Optional configuration * @param options - Optional configuration
* @returns Timer ID for manual clearing if needed * @returns Timer ID for manual clearing if needed
*/ */
setTimeout( setTimeout(callback: () => void, delay: number, options?: TimerOptions): string {
callback: () => void,
delay: number,
options?: TimerOptions
): string {
const id = uuidv4(); const id = uuidv4();
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
// Remove registration when timer fires naturally // Remove registration when timer fires naturally
@@ -148,11 +144,7 @@ export class CleanupManager implements Disposable {
* @param options - Optional configuration * @param options - Optional configuration
* @returns Interval ID for manual clearing if needed * @returns Interval ID for manual clearing if needed
*/ */
setInterval( setInterval(callback: () => void, delay: number, options?: TimerOptions): string {
callback: () => void,
delay: number,
options?: TimerOptions
): string {
const id = uuidv4(); const id = uuidv4();
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
// Don't execute if stopped // Don't execute if stopped
@@ -179,11 +171,7 @@ export class CleanupManager implements Disposable {
* @param description - Human-readable description * @param description - Human-readable description
* @returns Registration ID for manual removal if needed * @returns Registration ID for manual removal if needed
*/ */
registerCleanup( registerCleanup(type: CleanupResourceType, cleanup: () => void, description: string): string {
type: CleanupResourceType,
cleanup: () => void,
description: string
): string {
const id = uuidv4(); const id = uuidv4();
this.register({ this.register({
id, id,
@@ -202,10 +190,7 @@ export class CleanupManager implements Disposable {
* @param description - Human-readable description * @param description - Human-readable description
* @returns Registration ID * @returns Registration ID
*/ */
registerWatcher( registerWatcher(watcher: { close: () => void }, description: string): string {
watcher: { close: () => void },
description: string
): string {
return this.registerCleanup('watcher', () => watcher.close(), description); return this.registerCleanup('watcher', () => watcher.close(), description);
} }
@@ -218,19 +203,23 @@ export class CleanupManager implements Disposable {
* @param description - Human-readable description * @param description - Human-readable description
* @returns Registration ID * @returns Registration ID
*/ */
registerListener<T extends { removeListener?: (event: string, listener: () => void) => void; off?: (event: string, listener: () => void) => void }>( registerListener<
emitter: T, T extends {
event: string, removeListener?: (event: string, listener: () => void) => void;
listener: () => void, off?: (event: string, listener: () => void) => void;
description: string },
): string { >(emitter: T, event: string, listener: () => void, description: string): string {
return this.registerCleanup('listener', () => { return this.registerCleanup(
if (emitter.removeListener) { 'listener',
emitter.removeListener(event, listener); () => {
} else if (emitter.off) { if (emitter.removeListener) {
emitter.off(event, listener); emitter.removeListener(event, listener);
} } else if (emitter.off) {
}, description); emitter.off(event, listener);
}
},
description
);
} }
/** /**
@@ -240,17 +229,18 @@ export class CleanupManager implements Disposable {
* @param description - Human-readable description * @param description - Human-readable description
* @returns Registration ID * @returns Registration ID
*/ */
registerStream( registerStream(stream: { destroy?: () => void; close?: () => void }, description: string): string {
stream: { destroy?: () => void; close?: () => void }, return this.registerCleanup(
description: string 'stream',
): string { () => {
return this.registerCleanup('stream', () => { if (stream.destroy) {
if (stream.destroy) { stream.destroy();
stream.destroy(); } else if (stream.close) {
} else if (stream.close) { stream.close();
stream.close(); }
} },
}, description); description
);
} }
/** /**
@@ -297,8 +287,10 @@ export class CleanupManager implements Disposable {
this.registrations.clear(); this.registrations.clear();
if (errors.length > 0) { if (errors.length > 0) {
console.error(`[CleanupManager] ${errors.length} errors during disposal:`, console.error(
errors.map(e => e.description).join(', ')); `[CleanupManager] ${errors.length} errors during disposal:`,
errors.map((e) => e.description).join(', ')
);
} }
} }
+2 -11
View File
@@ -19,17 +19,8 @@ export {
createAnsiPatternSimple, createAnsiPatternSimple,
stripAnsi, stripAnsi,
} from './regex-patterns.js'; } from './regex-patterns.js';
export { export { MAX_SESSION_TOKENS, validateTokenCounts, validateTokensAndCost } from './token-validation.js';
MAX_SESSION_TOKENS, export { stringSimilarity, normalizePhrase, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
validateTokenCounts,
validateTokensAndCost,
} from './token-validation.js';
export {
stringSimilarity,
normalizePhrase,
fuzzyPhraseMatch,
todoContentHash,
} from './string-similarity.js';
export { assertNever } from './type-safety.js'; export { assertNever } from './type-safety.js';
export { wrapWithNice } from './nice-wrapper.js'; export { wrapWithNice } from './nice-wrapper.js';
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js'; export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
+12 -9
View File
@@ -17,13 +17,13 @@ const EXEC_TIMEOUT_MS = 5000;
/** Common directories where the OpenCode CLI binary may be installed */ /** Common directories where the OpenCode CLI binary may be installed */
const OPENCODE_SEARCH_DIRS = [ const OPENCODE_SEARCH_DIRS = [
join(homedir(), '.opencode', 'bin'), // Default install location join(homedir(), '.opencode', 'bin'), // Default install location
join(homedir(), '.local', 'bin'), // Alternative install location join(homedir(), '.local', 'bin'), // Alternative install location
'/usr/local/bin', // Homebrew / system '/usr/local/bin', // Homebrew / system
join(homedir(), 'go', 'bin'), // Go install join(homedir(), 'go', 'bin'), // Go install
join(homedir(), '.bun', 'bin'), // Bun global join(homedir(), '.bun', 'bin'), // Bun global
join(homedir(), '.npm-global', 'bin'), // npm global join(homedir(), '.npm-global', 'bin'), // npm global
join(homedir(), 'bin'), // User bin join(homedir(), 'bin'), // User bin
]; ];
/** Cached directory containing the opencode binary (empty string = searched but not found) */ /** Cached directory containing the opencode binary (empty string = searched but not found) */
@@ -41,7 +41,10 @@ export function resolveOpenCodeDir(): string | null {
// Try `which` first (respects current PATH) // Try `which` first (respects current PATH)
try { try {
const result = execSync('which opencode', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim(); const result = execSync('which opencode', {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
if (result && existsSync(result)) { if (result && existsSync(result)) {
_openCodeDir = dirname(result); _openCodeDir = dirname(result);
return _openCodeDir; return _openCodeDir;
@@ -58,7 +61,7 @@ export function resolveOpenCodeDir(): string | null {
} }
} }
_openCodeDir = ''; // mark as searched, not found _openCodeDir = ''; // mark as searched, not found
return null; return null;
} }
+5 -2
View File
@@ -17,6 +17,7 @@
* Use this when you need complete ANSI stripping including OSC sequences. * Use this when you need complete ANSI stripping including OSC sequences.
* Note: Has global flag - reset lastIndex before exec() if reusing. * Note: Has global flag - reset lastIndex before exec() if reusing.
*/ */
// eslint-disable-next-line no-control-regex
export const ANSI_ESCAPE_PATTERN_FULL = /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g; export const ANSI_ESCAPE_PATTERN_FULL = /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g;
/** /**
@@ -26,6 +27,7 @@ export const ANSI_ESCAPE_PATTERN_FULL = /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]
* Use this for faster stripping when OSC sequences aren't a concern. * Use this for faster stripping when OSC sequences aren't a concern.
* Note: Has global flag - reset lastIndex before exec() if reusing. * Note: Has global flag - reset lastIndex before exec() if reusing.
*/ */
// eslint-disable-next-line no-control-regex
export const ANSI_ESCAPE_PATTERN_SIMPLE = /\x1b\[[0-9;]*[A-Za-z]/g; export const ANSI_ESCAPE_PATTERN_SIMPLE = /\x1b\[[0-9;]*[A-Za-z]/g;
/** /**
@@ -43,6 +45,7 @@ export const TOKEN_PATTERN = /(\d+(?:\.\d+)?)\s*([kKmM])?\s*tokens/;
* Use when you need a pattern without shared lastIndex state. * Use when you need a pattern without shared lastIndex state.
*/ */
export function createAnsiPatternFull(): RegExp { export function createAnsiPatternFull(): RegExp {
// eslint-disable-next-line no-control-regex
return /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g; return /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g;
} }
@@ -51,6 +54,7 @@ export function createAnsiPatternFull(): RegExp {
* Use when you need a pattern without shared lastIndex state. * Use when you need a pattern without shared lastIndex state.
*/ */
export function createAnsiPatternSimple(): RegExp { export function createAnsiPatternSimple(): RegExp {
// eslint-disable-next-line no-control-regex
return /\x1b\[[0-9;]*[A-Za-z]/g; return /\x1b\[[0-9;]*[A-Za-z]/g;
} }
@@ -74,5 +78,4 @@ export function stripAnsi(text: string): string {
*/ */
export const SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧]/; export const SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧]/;
export const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_\/\-. ~]+$/; export const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_/\-. ~]+$/;
+10 -16
View File
@@ -245,29 +245,23 @@ export class StaleExpirationMap<K, V> implements Disposable {
/** /**
* Get all keys (non-expired). * Get all keys (non-expired).
*/ */
keys(): IterableIterator<K> { *keys(): IterableIterator<K> {
const self = this; for (const [key, entry] of this.entries) {
return (function* () { if (!this.isExpired(entry)) {
for (const [key, entry] of self.entries) { yield key;
if (!self.isExpired(entry)) {
yield key;
}
} }
})(); }
} }
/** /**
* Get all values (non-expired). * Get all values (non-expired).
*/ */
values(): IterableIterator<V> { *values(): IterableIterator<V> {
const self = this; for (const [, entry] of this.entries) {
return (function* () { if (!this.isExpired(entry)) {
for (const [, entry] of self.entries) { yield entry.value;
if (!self.isExpired(entry)) {
yield entry.value;
}
} }
})(); }
} }
/** /**
+5 -9
View File
@@ -55,8 +55,8 @@ export function levenshteinDistance(a: string, b: string): number {
for (let i = 1; i <= m; i++) { for (let i = 1; i <= m; i++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1; const cost = a[i - 1] === b[j - 1] ? 0 : 1;
curr[i] = Math.min( curr[i] = Math.min(
prev[i] + 1, // deletion prev[i] + 1, // deletion
curr[i - 1] + 1, // insertion curr[i - 1] + 1, // insertion
prev[i - 1] + cost // substitution prev[i - 1] + cost // substitution
); );
} }
@@ -139,7 +139,7 @@ export function isSimilarByDistance(a: string, b: string, maxDistance = 2): bool
export function normalizePhrase(phrase: string): string { export function normalizePhrase(phrase: string): string {
return phrase return phrase
.toUpperCase() .toUpperCase()
.replace(/[\s_\-\.]+/g, '') // Remove whitespace, underscores, hyphens, dots .replace(/[\s_\-.]+/g, '') // Remove whitespace, underscores, hyphens, dots
.trim(); .trim();
} }
@@ -161,11 +161,7 @@ export function normalizePhrase(phrase: string): string {
* fuzzyPhraseMatch('TASK_DONE', 'TASKDONE') // true (separator) * fuzzyPhraseMatch('TASK_DONE', 'TASKDONE') // true (separator)
* fuzzyPhraseMatch('COMPLETE', 'FINISHED') // false (different word) * fuzzyPhraseMatch('COMPLETE', 'FINISHED') // false (different word)
*/ */
export function fuzzyPhraseMatch( export function fuzzyPhraseMatch(phrase1: string, phrase2: string, maxDistance = 2): boolean {
phrase1: string,
phrase2: string,
maxDistance = 2
): boolean {
const norm1 = normalizePhrase(phrase1); const norm1 = normalizePhrase(phrase1);
const norm2 = normalizePhrase(phrase2); const norm2 = normalizePhrase(phrase2);
@@ -201,7 +197,7 @@ export function todoContentHash(content: string): string {
let hash = 0; let hash = 0;
for (let i = 0; i < normalized.length; i++) { for (let i = 0; i < normalized.length; i++) {
const char = normalized.charCodeAt(i); const char = normalized.charCodeAt(i);
hash = ((hash << 5) - hash) + char; hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer hash = hash & hash; // Convert to 32-bit integer
} }
return hash.toString(36); return hash.toString(36);
+1 -4
View File
@@ -21,10 +21,7 @@ export const MAX_SESSION_TOKENS = 500_000;
* @param outputTokens - Output token count to validate * @param outputTokens - Output token count to validate
* @returns Object with isValid flag and optional error reason * @returns Object with isValid flag and optional error reason
*/ */
export function validateTokenCounts( export function validateTokenCounts(inputTokens: number, outputTokens: number): { isValid: boolean; reason?: string } {
inputTokens: number,
outputTokens: number
): { isValid: boolean; reason?: string } {
if (inputTokens < 0 || outputTokens < 0) { if (inputTokens < 0 || outputTokens < 0) {
return { return {
isValid: false, isValid: false,
+184 -112
View File
@@ -15,11 +15,23 @@ import { SAFE_PATH_PATTERN } from '../utils/regex-patterns.js';
/** Validate a path string: no shell metacharacters, no traversal, must be absolute */ /** Validate a path string: no shell metacharacters, no traversal, must be absolute */
export function isValidWorkingDir(p: string): boolean { export function isValidWorkingDir(p: string): boolean {
if (!p || !p.startsWith('/')) return false; if (!p || !p.startsWith('/')) return false;
if (p.includes(';') || p.includes('&') || p.includes('|') || if (
p.includes('$') || p.includes('`') || p.includes('(') || p.includes(';') ||
p.includes(')') || p.includes('{') || p.includes('}') || p.includes('&') ||
p.includes('<') || p.includes('>') || p.includes("'") || p.includes('|') ||
p.includes('"') || p.includes('\n') || p.includes('\r')) { p.includes('$') ||
p.includes('`') ||
p.includes('(') ||
p.includes(')') ||
p.includes('{') ||
p.includes('}') ||
p.includes('<') ||
p.includes('>') ||
p.includes("'") ||
p.includes('"') ||
p.includes('\n') ||
p.includes('\r')
) {
return false; return false;
} }
if (p.includes('..')) return false; if (p.includes('..')) return false;
@@ -38,25 +50,35 @@ const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_'];
/** Env var keys that are always blocked (security-sensitive) */ /** Env var keys that are always blocked (security-sensitive) */
const BLOCKED_ENV_KEYS = new Set([ const BLOCKED_ENV_KEYS = new Set([
'PATH', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'NODE_OPTIONS', 'PATH',
'CODEMAN_MUX_NAME', 'CODEMAN_TMUX', 'LD_PRELOAD',
'OPENCODE_SERVER_PASSWORD', // Security-sensitive: server auth password 'LD_LIBRARY_PATH',
'NODE_OPTIONS',
'CODEMAN_MUX_NAME',
'CODEMAN_TMUX',
'OPENCODE_SERVER_PASSWORD', // Security-sensitive: server auth password
]); ]);
/** Validate that an env var key is allowed */ /** Validate that an env var key is allowed */
function isAllowedEnvKey(key: string): boolean { function isAllowedEnvKey(key: string): boolean {
if (BLOCKED_ENV_KEYS.has(key)) return false; if (BLOCKED_ENV_KEYS.has(key)) return false;
return ALLOWED_ENV_PREFIXES.some(prefix => key.startsWith(prefix)); return ALLOWED_ENV_PREFIXES.some((prefix) => key.startsWith(prefix));
} }
/** Zod schema for env overrides with allowlist enforcement */ /** Zod schema for env overrides with allowlist enforcement */
const safeEnvOverridesSchema = z.record(z.string(), z.string()).optional().refine( const safeEnvOverridesSchema = z
(val) => { .record(z.string(), z.string())
if (!val) return true; .optional()
return Object.keys(val).every(isAllowedEnvKey); .refine(
}, (val) => {
{ message: 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_* and OPENCODE_* keys are allowed.' }, if (!val) return true;
); return Object.keys(val).every(isAllowedEnvKey);
},
{
message:
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_* and OPENCODE_* keys are allowed.',
}
);
// ========== Session Routes ========== // ========== Session Routes ==========
@@ -65,16 +87,37 @@ const safeEnvOverridesSchema = z.record(z.string(), z.string()).optional().refin
* Creates a new session with optional working directory, mode, and name. * Creates a new session with optional working directory, mode, and name.
*/ */
/** Schema for OpenCode-specific configuration */ /** Schema for OpenCode-specific configuration */
const OpenCodeConfigSchema = z.object({ const OpenCodeConfigSchema = z
model: z.string().max(100).regex(/^[a-zA-Z0-9._\-/]+$/).optional(), .object({
autoAllowTools: z.boolean().optional(), model: z
continueSession: z.string().max(100).regex(/^[a-zA-Z0-9_-]+$/).optional(), .string()
forkSession: z.boolean().optional(), .max(100)
configContent: z.string().max(10000).refine( .regex(/^[a-zA-Z0-9._\-/]+$/)
(val) => { try { JSON.parse(val); return true; } catch { return false; } }, .optional(),
{ message: 'configContent must be valid JSON' }, autoAllowTools: z.boolean().optional(),
).optional(), continueSession: z
}).optional(); .string()
.max(100)
.regex(/^[a-zA-Z0-9_-]+$/)
.optional(),
forkSession: z.boolean().optional(),
configContent: z
.string()
.max(10000)
.refine(
(val) => {
try {
JSON.parse(val);
return true;
} catch {
return false;
}
},
{ message: 'configContent must be valid JSON' }
)
.optional(),
})
.optional();
export const CreateSessionSchema = z.object({ export const CreateSessionSchema = z.object({
workingDir: safePathSchema.optional(), workingDir: safePathSchema.optional(),
@@ -108,7 +151,9 @@ export const ResizeSchema = z.object({
* Creates a new case folder. * Creates a new case folder.
*/ */
export const CreateCaseSchema = z.object({ export const CreateCaseSchema = z.object({
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.'), name: z
.string()
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.'),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
}); });
@@ -119,7 +164,10 @@ export const CreateCaseSchema = z.object({
* Creates case (if needed) and starts interactive session. * Creates case (if needed) and starts interactive session.
*/ */
export const QuickStartSchema = z.object({ export const QuickStartSchema = z.object({
caseName: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.').optional(), caseName: z
.string()
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.')
.optional(),
mode: z.enum(['claude', 'shell', 'opencode']).optional(), mode: z.enum(['claude', 'shell', 'opencode']).optional(),
openCodeConfig: OpenCodeConfigSchema, openCodeConfig: OpenCodeConfigSchema,
}); });
@@ -175,86 +223,100 @@ export const RespawnConfigSchema = z.object({
* Schema for PUT /api/config * Schema for PUT /api/config
* Updates application configuration with whitelist of allowed fields. * Updates application configuration with whitelist of allowed fields.
*/ */
export const ConfigUpdateSchema = z.object({ export const ConfigUpdateSchema = z
pollIntervalMs: z.number().int().min(100).max(60000).optional(), .object({
defaultTimeoutMs: z.number().int().min(1000).max(3600000).optional(), pollIntervalMs: z.number().int().min(100).max(60000).optional(),
maxConcurrentSessions: z.number().int().min(1).max(50).optional(), defaultTimeoutMs: z.number().int().min(1000).max(3600000).optional(),
respawn: RespawnConfigSchema.optional(), maxConcurrentSessions: z.number().int().min(1).max(50).optional(),
}).strict(); respawn: RespawnConfigSchema.optional(),
})
.strict();
/** /**
* Schema for PUT /api/settings * Schema for PUT /api/settings
* Explicit allowlist of known settings fields — prevents arbitrary key persistence. * Explicit allowlist of known settings fields — prevents arbitrary key persistence.
*/ */
const NotificationEventSchema = z.object({ const NotificationEventSchema = z
enabled: z.boolean().optional(), .object({
browser: z.boolean().optional(), enabled: z.boolean().optional(),
audio: z.boolean().optional(), browser: z.boolean().optional(),
push: z.boolean().optional(), audio: z.boolean().optional(),
}).optional(); push: z.boolean().optional(),
})
.optional();
export const SettingsUpdateSchema = z.object({ export const SettingsUpdateSchema = z
// Paths .object({
defaultClaudeMdPath: z.string().max(500).optional(), // Paths
defaultWorkingDir: z.string().max(500).optional(), defaultClaudeMdPath: z.string().max(500).optional(),
lastUsedCase: z.string().max(200).optional(), defaultWorkingDir: z.string().max(500).optional(),
// Feature toggles lastUsedCase: z.string().max(200).optional(),
ralphTrackerEnabled: z.boolean().optional(), // Feature toggles
subagentTrackingEnabled: z.boolean().optional(), ralphTrackerEnabled: z.boolean().optional(),
subagentActiveTabOnly: z.boolean().optional(), subagentTrackingEnabled: z.boolean().optional(),
imageWatcherEnabled: z.boolean().optional(), subagentActiveTabOnly: z.boolean().optional(),
tunnelEnabled: z.boolean().optional(), imageWatcherEnabled: z.boolean().optional(),
tabTwoRows: z.boolean().optional(), tunnelEnabled: z.boolean().optional(),
agentTeamsEnabled: z.boolean().optional(), tabTwoRows: z.boolean().optional(),
// UI visibility agentTeamsEnabled: z.boolean().optional(),
showFontControls: z.boolean().optional(), // UI visibility
showSystemStats: z.boolean().optional(), showFontControls: z.boolean().optional(),
showTokenCount: z.boolean().optional(), showSystemStats: z.boolean().optional(),
showCost: z.boolean().optional(), showTokenCount: z.boolean().optional(),
showLifecycleLog: z.boolean().optional(), showCost: z.boolean().optional(),
showMonitor: z.boolean().optional(), showLifecycleLog: z.boolean().optional(),
showProjectInsights: z.boolean().optional(), showMonitor: z.boolean().optional(),
showFileBrowser: z.boolean().optional(), showProjectInsights: z.boolean().optional(),
showSubagents: z.boolean().optional(), showFileBrowser: z.boolean().optional(),
// Claude CLI settings showSubagents: z.boolean().optional(),
claudeMode: z.string().max(50).optional(), // Claude CLI settings
allowedTools: z.string().max(2000).optional(), claudeMode: z.string().max(50).optional(),
// CPU priority allowedTools: z.string().max(2000).optional(),
nice: z.object({ // CPU priority
enabled: z.boolean().optional(), nice: z
niceValue: z.number().int().min(-20).max(19).optional(), .object({
}).optional(), enabled: z.boolean().optional(),
// Notification preferences (cross-device sync) niceValue: z.number().int().min(-20).max(19).optional(),
notificationPreferences: z.object({ })
enabled: z.boolean().optional(), .optional(),
browserNotifications: z.boolean().optional(), // Notification preferences (cross-device sync)
audioAlerts: z.boolean().optional(), notificationPreferences: z
stuckThresholdMs: z.number().optional(), .object({
muteCritical: z.boolean().optional(), enabled: z.boolean().optional(),
muteWarning: z.boolean().optional(), browserNotifications: z.boolean().optional(),
muteInfo: z.boolean().optional(), audioAlerts: z.boolean().optional(),
eventTypes: z.object({ stuckThresholdMs: z.number().optional(),
permission_prompt: NotificationEventSchema, muteCritical: z.boolean().optional(),
elicitation_dialog: NotificationEventSchema, muteWarning: z.boolean().optional(),
idle_prompt: NotificationEventSchema, muteInfo: z.boolean().optional(),
stop: NotificationEventSchema, eventTypes: z
session_error: NotificationEventSchema, .object({
respawn_cycle: NotificationEventSchema, permission_prompt: NotificationEventSchema,
token_milestone: NotificationEventSchema, elicitation_dialog: NotificationEventSchema,
ralph_complete: NotificationEventSchema, idle_prompt: NotificationEventSchema,
subagent_spawn: NotificationEventSchema, stop: NotificationEventSchema,
subagent_complete: NotificationEventSchema, session_error: NotificationEventSchema,
}).optional(), respawn_cycle: NotificationEventSchema,
_version: z.number().optional(), token_milestone: NotificationEventSchema,
}).optional(), ralph_complete: NotificationEventSchema,
// Voice settings (cross-device sync) subagent_spawn: NotificationEventSchema,
voiceSettings: z.object({ subagent_complete: NotificationEventSchema,
apiKey: z.string().max(200).optional(), })
language: z.string().max(20).optional(), .optional(),
keyterms: z.string().max(500).optional(), _version: z.number().optional(),
insertMode: z.string().max(20).optional(), })
}).optional(), .optional(),
}).strict(); // Voice settings (cross-device sync)
voiceSettings: z
.object({
apiKey: z.string().max(200).optional(),
language: z.string().max(20).optional(),
keyterms: z.string().max(500).optional(),
insertMode: z.string().max(20).optional(),
})
.optional(),
})
.strict();
/** /**
* Schema for POST /api/sessions/:id/input with length limit * Schema for POST /api/sessions/:id/input with length limit
@@ -381,10 +443,12 @@ export const CpuLimitSchema = z.object({
export const ModelConfigUpdateSchema = z.record(z.string(), z.unknown()); export const ModelConfigUpdateSchema = z.record(z.string(), z.unknown());
/** PUT /api/subagent-window-states */ /** PUT /api/subagent-window-states */
export const SubagentWindowStatesSchema = z.object({ export const SubagentWindowStatesSchema = z
minimized: z.record(z.string(), z.boolean()).optional(), .object({
open: z.array(z.string()).optional(), minimized: z.record(z.string(), z.boolean()).optional(),
}).passthrough(); open: z.array(z.string()).optional(),
})
.passthrough();
/** PUT /api/subagent-parents */ /** PUT /api/subagent-parents */
export const SubagentParentMapSchema = z.record(z.string(), z.string()); export const SubagentParentMapSchema = z.record(z.string(), z.string());
@@ -423,14 +487,22 @@ export const PushPreferencesUpdateSchema = z.object({
/** POST /api/ralph-loop/start */ /** POST /api/ralph-loop/start */
export const RalphLoopStartSchema = z.object({ export const RalphLoopStartSchema = z.object({
caseName: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format').optional().default('testcase'), caseName: z
.string()
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')
.optional()
.default('testcase'),
taskDescription: z.string().min(1).max(100000), taskDescription: z.string().min(1).max(100000),
completionPhrase: z.string().max(100).default('COMPLETE'), completionPhrase: z.string().max(100).default('COMPLETE'),
maxIterations: z.number().int().min(0).max(1000).nullable().default(10), maxIterations: z.number().int().min(0).max(1000).nullable().default(10),
enableRespawn: z.boolean().default(false), enableRespawn: z.boolean().default(false),
planItems: z.array(z.object({ planItems: z
content: z.string(), .array(
priority: z.string().optional(), z.object({
enabled: z.boolean().default(true), content: z.string(),
})).optional(), priority: z.string().optional(),
enabled: z.boolean().default(true),
})
)
.optional(),
}); });
+602 -256
View File
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB