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
*.temp
# Generated output
out/
screenshots-echo-diag/
tools/remotion/out/
# Claude Code plan tracking
plan.json
# Unfinished TUI (local development only)
src/tui/
.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
**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`)
@@ -92,7 +92,6 @@ journalctl --user -u codeman-web -f
## 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
- **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.
+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/**',
],
}
);
+1926 -2603
View File
File diff suppressed because it is too large Load Diff
+16 -5
View File
@@ -10,20 +10,27 @@
},
"scripts": {
"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",
"dev": "tsx src/index.ts",
"dev": "tsx src/index.ts web",
"web": "node dist/index.js web",
"clean": "rm -rf dist",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"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",
"changeset": "changeset",
"version-packages": "changeset version",
"release": "changeset publish"
},
"workspaces": [
"packages/*"
],
"keywords": [
"claude",
"claude-code",
@@ -43,15 +50,12 @@
"@fastify/compress": "^8.3.1",
"@fastify/cookie": "^11.0.2",
"@fastify/static": "^8.0.0",
"@remotion/cli": "4.0.429",
"@remotion/transitions": "4.0.429",
"chalk": "^5.3.0",
"chokidar": "^3.6.0",
"commander": "^12.1.0",
"fastify": "^5.1.0",
"node-pty": "^1.1.0",
"qrcode": "^1.5.4",
"remotion": "4.0.429",
"uuid": "^10.0.0",
"web-push": "^3.6.7",
"xterm": "^5.3.0",
@@ -62,6 +66,9 @@
},
"devDependencies": {
"@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/pngjs": "^6.0.5",
"@types/react": "^19.2.14",
@@ -70,12 +77,16 @@
"@vitest/coverage-v8": "^4.0.18",
"agent-browser": "^0.6.0",
"esbuild": "^0.27.3",
"eslint": "^9.0.0",
"pixelmatch": "^6.0.0",
"playwright": "^1.58.0",
"pngjs": "^7.0.0",
"prettier": "^3.4.0",
"puppeteer": "^24.36.0",
"remotion": "4.0.429",
"tsx": "^4.15.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.0.0",
"vitest": "^4.0.18"
},
"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,
C extends AiCheckerConfigBase,
R extends AiCheckerResultBase<V>,
S extends AiCheckerStateBase<V>
S extends AiCheckerStateBase<V>,
> extends EventEmitter {
protected config: C;
protected sessionId: string;
@@ -205,9 +205,7 @@ export abstract class AiCheckerBase<
super();
this.sessionId = sessionId;
// Filter out undefined values to prevent overwriting defaults
const filteredConfig = Object.fromEntries(
Object.entries(config).filter(([, v]) => v !== undefined)
) as Partial<C>;
const filteredConfig = Object.fromEntries(Object.entries(config).filter(([, v]) => v !== undefined)) as Partial<C>;
this.config = { ...defaultConfig, ...filteredConfig };
}
@@ -342,9 +340,7 @@ export abstract class AiCheckerBase<
/** Update configuration at runtime */
updateConfig(config: Partial<C>): void {
// Filter out undefined values to prevent overwriting existing config
const filteredConfig = Object.fromEntries(
Object.entries(config).filter(([, v]) => v !== undefined)
) as Partial<C>;
const filteredConfig = Object.fromEntries(Object.entries(config).filter(([, v]) => v !== undefined)) as Partial<C>;
this.config = { ...this.config, ...filteredConfig };
if (config.enabled === false) {
this.disable('Disabled by config');
@@ -369,9 +365,8 @@ export abstract class AiCheckerBase<
// Prepare the terminal buffer (strip ANSI, trim to maxContextChars)
const stripped = terminalBuffer.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '');
const trimmed = stripped.length > this.config.maxContextChars
? stripped.slice(-this.config.maxContextChars)
: stripped;
const trimmed =
stripped.length > this.config.maxContextChars ? stripped.slice(-this.config.maxContextChars) : stripped;
// Build the prompt
const prompt = this.buildPrompt(trimmed);
@@ -411,16 +406,15 @@ export abstract class AiCheckerBase<
// No existing session, that's fine
}
const muxProcess = childSpawn('tmux', [
'new-session', '-d', '-s', this.checkMuxName,
'bash', '-c', fullCmd
], {
const muxProcess = childSpawn('tmux', ['new-session', '-d', '-s', this.checkMuxName, 'bash', '-c', fullCmd], {
detached: true,
stdio: 'ignore',
});
muxProcess.unref();
} catch (err) {
throw new Error(`Failed to spawn ${this.checkDescription} tmux session: ${err instanceof Error ? err.message : String(err)}`);
throw new Error(
`Failed to spawn ${this.checkDescription} tmux session: ${err instanceof Error ? err.message : String(err)}`
);
}
// Poll the temp file for completion
@@ -530,7 +524,9 @@ export abstract class AiCheckerBase<
private handleError(errorMsg: string): void {
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) {
this.disable(`${this.config.maxConsecutiveErrors} consecutive errors: ${errorMsg}`);
+4 -9
View File
@@ -33,13 +33,13 @@ import {
// ========== Types ==========
export interface AiIdleCheckConfig extends AiCheckerConfigBase {}
export type AiIdleCheckConfig = AiCheckerConfigBase;
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 ==========
@@ -123,12 +123,7 @@ Remember: When uncertain, answer WORKING.`;
* Manages AI-powered idle detection by spawning a fresh Claude CLI session
* to analyze terminal output and provide a definitive IDLE/WORKING verdict.
*/
export class AiIdleChecker extends AiCheckerBase<
AiCheckVerdict,
AiIdleCheckConfig,
AiCheckResult,
AiCheckState
> {
export class AiIdleChecker extends AiCheckerBase<AiCheckVerdict, AiIdleCheckConfig, AiCheckResult, AiCheckState> {
protected readonly muxNamePrefix = 'codeman-aicheck-';
protected readonly doneMarker = '__AICHECK_DONE__';
protected readonly tempFilePrefix = 'codeman-aicheck';
+3 -3
View File
@@ -32,13 +32,13 @@ import {
// ========== Types ==========
export interface AiPlanCheckConfig extends AiCheckerConfigBase {}
export type AiPlanCheckConfig = AiCheckerConfigBase;
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 ==========
+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
* 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.)
*/
const INVALID_PATH_PATTERN = /^[\/~]-|\/dev\/null$/;
const INVALID_PATH_PATTERN = /^[/~]-|\/dev\/null$/;
/**
* Pattern to detect command suggestions in plain text output.
* Matches lines like "tail -f /path/to/file" without the ● Bash() wrapper.
* 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).
* 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 ==========
@@ -245,7 +245,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
*/
private isShallowRootPath(path: string): boolean {
if (!path.startsWith('/')) return false;
const parts = path.split('/').filter(p => p !== '');
const parts = path.split('/').filter((p) => p !== '');
return parts.length === 1;
}
@@ -354,10 +354,10 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
isFilePathTracked(filePath: string): boolean {
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;
return t.filePaths.some(existingPath => {
return t.filePaths.some((existingPath) => {
const normalizedExisting = this.normalizePath(existingPath);
return normalizedExisting === normalizedNew;
});
@@ -418,9 +418,8 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
// Prevent unbounded growth
if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) {
const trimPoint = this._lineBuffer.lastIndexOf('\n', MAX_LINE_BUFFER_SIZE / 2);
this._lineBuffer = trimPoint > 0
? this._lineBuffer.slice(trimPoint + 1)
: this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
this._lineBuffer =
trimPoint > 0 ? this._lineBuffer.slice(trimPoint + 1) : this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
}
// Process complete lines
@@ -445,9 +444,8 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) {
const trimPoint = this._lineBuffer.lastIndexOf('\n', MAX_LINE_BUFFER_SIZE / 2);
this._lineBuffer = trimPoint > 0
? this._lineBuffer.slice(trimPoint + 1)
: this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
this._lineBuffer =
trimPoint > 0 ? this._lineBuffer.slice(trimPoint + 1) : this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
}
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.
*/
private processCleanLine(cleanLine: string): void {
// Check for tool start
const startMatch = cleanLine.match(BASH_TOOL_START_PATTERN);
if (startMatch) {
@@ -484,7 +481,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
const filePaths = this.extractFilePaths(command);
// 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;
}
@@ -502,8 +499,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
// Enforce max tools limit
if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
// Remove oldest tool
const oldest = Array.from(this._activeTools.entries())
.sort((a, b) => a[1].startedAt - b[1].startedAt)[0];
const oldest = Array.from(this._activeTools.entries()).sort((a, b) => a[1].startedAt - b[1].startedAt)[0];
if (oldest) {
this._activeTools.delete(oldest[0]);
}
@@ -671,6 +667,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
*/
private stripAnsi(str: string): string {
// Comprehensive ANSI pattern
// eslint-disable-next-line no-control-regex
return str.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g, '');
}
+19 -32
View File
@@ -21,17 +21,11 @@ const pkg = require('../package.json') as { version: string };
const program = new Command();
program
.name('codeman')
.description('Claude Code session manager with autonomous Ralph Loop')
.version(pkg.version);
program.name('codeman').description('Claude Code session manager with autonomous Ralph Loop').version(pkg.version);
// ============ Session Commands ============
const sessionCmd = program
.command('session')
.alias('s')
.description('Manage Claude sessions');
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
sessionCmd
.command('start')
@@ -83,7 +77,8 @@ sessionCmd
console.log(' (none)');
} else {
for (const session of sessions) {
const status = session.status === 'idle'
const status =
session.status === 'idle'
? chalk.green('idle')
: session.status === 'busy'
? chalk.yellow('busy')
@@ -106,7 +101,8 @@ sessionCmd
if (sessions.length === 0 && activeSessions.length > 0) {
console.log(chalk.bold('\nActive Sessions (from web server):'));
for (const session of activeSessions) {
const status = session.status === 'idle'
const status =
session.status === 'idle'
? chalk.green('idle')
: session.status === 'busy'
? chalk.yellow('busy')
@@ -126,9 +122,7 @@ sessionCmd
.option('-e, --errors', 'Show stderr instead of stdout')
.action((id, options) => {
const manager = getSessionManager();
const output = options.errors
? manager.getSessionError(id)
: manager.getSessionOutput(id);
const output = options.errors ? manager.getSessionError(id) : manager.getSessionOutput(id);
if (output === null) {
console.log(chalk.yellow(`Session ${id} not found or not active`));
@@ -145,10 +139,7 @@ sessionCmd
// ============ Task Commands ============
const taskCmd = program
.command('task')
.alias('t')
.description('Manage tasks');
const taskCmd = program.command('task').alias('t').description('Manage tasks');
taskCmd
.command('add <prompt>')
@@ -205,7 +196,9 @@ taskCmd
const counts = queue.getCount();
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('');
});
@@ -276,10 +269,7 @@ taskCmd
// ============ Ralph Loop Commands ============
const ralphCmd = program
.command('ralph')
.alias('r')
.description('Control the Ralph autonomous loop');
const ralphCmd = program.command('ralph').alias('r').description('Control the Ralph autonomous loop');
ralphCmd
.command('start')
@@ -355,17 +345,16 @@ ralphCmd
});
function printStats(stats: ReturnType<ReturnType<typeof getRalphLoop>['getStats']>) {
const statusColor =
stats.status === 'running' ? chalk.green :
stats.status === 'paused' ? chalk.yellow :
chalk.gray;
const statusColor = stats.status === 'running' ? chalk.green : stats.status === 'paused' ? chalk.yellow : chalk.gray;
console.log(chalk.bold('\nRalph Loop Status:'));
console.log(` Status: ${statusColor(stats.status)}`);
console.log(` Elapsed: ${stats.elapsedHours.toFixed(2)} hours`);
if (stats.minDurationMs) {
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:'));
@@ -422,10 +411,7 @@ program
console.log(` Completed: ${taskCounts.completed}`);
console.log(` Failed: ${taskCounts.failed}`);
const statusColor =
loopStatus === 'running' ? chalk.green :
loopStatus === 'paused' ? chalk.yellow :
chalk.gray;
const statusColor = loopStatus === 'running' ? chalk.green : loopStatus === 'paused' ? chalk.yellow : chalk.gray;
console.log(chalk.bold('\nRalph Loop:'));
console.log(` Status: ${statusColor(loopStatus)}`);
console.log('');
@@ -481,7 +467,8 @@ program
console.log(' (none)');
} else {
for (const session of sessions) {
const status = session.status === 'idle'
const status =
session.status === 'idle'
? chalk.green('idle')
: session.status === 'busy'
? chalk.yellow('busy')
+5 -4
View File
@@ -171,7 +171,10 @@ export class FileStreamManager extends EventEmitter {
}
} catch (err) {
const errorCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : 'UNKNOWN';
console.warn(`[FileStreamManager] Failed to stat file "${absolutePath}" (${errorCode}):`, err instanceof Error ? err.message : String(err));
console.warn(
`[FileStreamManager] Failed to stat file "${absolutePath}" (${errorCode}):`,
err instanceof Error ? err.message : String(err)
);
return { success: false, error: 'File not found or not accessible' };
}
@@ -379,9 +382,7 @@ export class FileStreamManager extends EventEmitter {
}
// Resolve to absolute path
let absolutePath = isAbsolute(expandedPath)
? resolve(expandedPath)
: resolve(workingDir, expandedPath);
let absolutePath = isAbsolute(expandedPath) ? resolve(expandedPath) : resolve(workingDir, expandedPath);
// 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.
+7 -10
View File
@@ -24,15 +24,7 @@ export interface ImageWatcherEvents {
// ========== Constants ==========
/** Supported image file extensions (lowercase) */
const IMAGE_EXTENSIONS = new Set([
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.bmp',
'.svg',
]);
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
/** Time to wait for file writes to stabilize (ms) */
const STABILITY_THRESHOLD_MS = 500;
@@ -171,7 +163,12 @@ export class ImageWatcher extends EventEmitter {
// Ignore common heavy directories for performance
ignored: (path: string) => {
// 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;
}
const ext = extname(path).toLowerCase();
+3 -1
View File
@@ -23,7 +23,9 @@ let errorResetTimer: ReturnType<typeof setTimeout> | null = null;
function trackError(): void {
consecutiveErrors++;
if (errorResetTimer) clearTimeout(errorResetTimer);
errorResetTimer = setTimeout(() => { consecutiveErrors = 0; }, ERROR_RESET_MS);
errorResetTimer = setTimeout(() => {
consecutiveErrors = 0;
}, ERROR_RESET_MS);
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
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 { ProcessStats, PersistedRespawnConfig, NiceConfig, ClaudeMode, SessionMode, OpenCodeConfig } from './types.js';
import type {
ProcessStats,
PersistedRespawnConfig,
NiceConfig,
ClaudeMode,
SessionMode,
OpenCodeConfig,
} from './types.js';
/**
* Multiplexer session metadata.
+178 -30
View File
@@ -19,10 +19,7 @@ import { Session } from './session.js';
import type { TerminalMultiplexer } from './mux-interface.js';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import {
RESEARCH_AGENT_PROMPT,
PLANNER_PROMPT,
} from './prompts/index.js';
import { RESEARCH_AGENT_PROMPT, PLANNER_PROMPT } from './prompts/index.js';
import { PlanTaskStatus, TddPhase } from './types.js';
// ============================================================================
@@ -170,7 +167,7 @@ export class PlanOrchestrator {
mux: TerminalMultiplexer,
workingDir: string = process.cwd(),
outputDir?: string,
modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record<string, string> },
modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record<string, string> }
) {
this.mux = mux;
this.workingDir = workingDir;
@@ -193,7 +190,11 @@ export class PlanOrchestrator {
}
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');
writeFileSync(resultPath, JSON.stringify(result, null, 2), 'utf-8');
@@ -227,9 +228,9 @@ export class PlanOrchestrator {
private generateSummary(result: DetailedPlanResult): string {
const items = result.items || [];
const p0 = items.filter(i => i.priority === 'P0');
const p1 = items.filter(i => i.priority === 'P1');
const p2 = items.filter(i => i.priority === 'P2');
const p0 = items.filter((i) => i.priority === 'P0');
const p1 = items.filter((i) => i.priority === 'P1');
const p2 = items.filter((i) => i.priority === 'P2');
let md = `# Plan Summary\n\n`;
md += `Generated: ${new Date().toISOString()}\n`;
@@ -392,10 +393,29 @@ export class PlanOrchestrator {
const startTime = Date.now();
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({
workingDir: this.workingDir,
@@ -411,7 +431,14 @@ export class PlanOrchestrator {
// Start progress interval before try block to ensure cleanup in finally
const progressInterval = setInterval(() => {
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);
try {
@@ -424,14 +451,54 @@ export class PlanOrchestrator {
// Extract JSON from response
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
onSubagent?.({ type: 'failed', agentId, agentType: 'research', model: this.researchModel, status: 'failed', error: 'No JSON found', durationMs });
return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error: 'No JSON in response', durationMs };
onSubagent?.({
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]);
if (!parsed.success) {
onSubagent?.({ 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 };
onSubagent?.({
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>;
@@ -444,20 +511,48 @@ export class PlanOrchestrator {
potentialChallenges: Array.isArray(data.potentialChallenges) ? data.potentialChallenges : [],
recommendedTools: Array.isArray(data.recommendedTools) ? data.recommendedTools : [],
},
enrichedTaskDescription: typeof data.enrichedTaskDescription === 'string' ? data.enrichedTaskDescription : taskDescription,
enrichedTaskDescription:
typeof data.enrichedTaskDescription === 'string' ? data.enrichedTaskDescription : taskDescription,
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;
} catch (err) {
this.runningSessions.delete(session);
const durationMs = Date.now() - startTime;
const error = err instanceof Error ? err.message : String(err);
onSubagent?.({ type: 'failed', agentId, agentType: 'research', model: this.researchModel, status: 'failed', error, durationMs });
return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error, durationMs };
onSubagent?.({
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 {
// Always clear the progress interval to prevent memory leaks
clearInterval(progressInterval);
@@ -469,7 +564,13 @@ export class PlanOrchestrator {
researchContext: string,
onProgress?: ProgressCallback,
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 startTime = Date.now();
@@ -477,7 +578,14 @@ export class PlanOrchestrator {
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({
workingDir: this.workingDir,
@@ -488,14 +596,22 @@ export class PlanOrchestrator {
this.runningSessions.add(session);
const prompt = PLANNER_PROMPT
.replace('{TASK}', taskDescription)
.replace('{RESEARCH_CONTEXT}', researchContext || '');
const prompt = PLANNER_PROMPT.replace('{TASK}', taskDescription).replace(
'{RESEARCH_CONTEXT}',
researchContext || ''
);
// Start progress interval before try block to ensure cleanup in finally
const progressInterval = setInterval(() => {
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);
try {
@@ -508,13 +624,29 @@ export class PlanOrchestrator {
// Extract JSON from response
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
onSubagent?.({ type: 'failed', agentId, agentType: 'planner', model: this.plannerModel, status: 'failed', error: 'No JSON found', durationMs });
onSubagent?.({
type: 'failed',
agentId,
agentType: 'planner',
model: this.plannerModel,
status: 'failed',
error: 'No JSON found',
durationMs,
});
return { success: false, error: 'No JSON in response' };
}
const parsed = tryParseJSON(jsonMatch[0]);
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 };
}
@@ -524,7 +656,15 @@ export class PlanOrchestrator {
const warnings: string[] = Array.isArray(data.warnings) ? data.warnings : [];
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`);
@@ -533,7 +673,15 @@ export class PlanOrchestrator {
this.runningSessions.delete(session);
const durationMs = Date.now() - startTime;
const error = err instanceof Error ? err.message : String(err);
onSubagent?.({ type: 'failed', agentId, agentType: 'planner', model: this.plannerModel, status: 'failed', error, durationMs });
onSubagent?.({
type: 'failed',
agentId,
agentType: 'planner',
model: this.plannerModel,
status: 'failed',
error,
durationMs,
});
return { success: false, error };
} finally {
// 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() });
// Run independent checks in parallel for better performance
await Promise.all([
this.checkTimeouts(),
this.assignTasks(),
]);
await Promise.all([this.checkTimeouts(), this.assignTasks()]);
// Check if we should auto-generate tasks (depends on assignment results)
if (this.autoGenerateTasks && this.shouldGenerateTasks()) {
@@ -409,11 +406,7 @@ export class RalphLoop extends EventEmitter {
// 2. Min duration not reached
// 3. We have idle sessions
const counts = this.taskQueue.getCount();
return (
counts.pending === 0 &&
!this.isMinDurationReached() &&
this.sessionManager.getIdleSessions().length > 0
);
return counts.pending === 0 && !this.isMinDurationReached() && this.sessionManager.getIdleSessions().length > 0;
}
private async generateFollowUpTasks(): Promise<void> {
+107 -82
View File
@@ -34,12 +34,7 @@ import {
PlanTaskStatus,
TddPhase,
} from './types.js';
import {
ANSI_ESCAPE_PATTERN_SIMPLE,
fuzzyPhraseMatch,
todoContentHash,
stringSimilarity,
} from './utils/index.js';
import { ANSI_ESCAPE_PATTERN_SIMPLE, fuzzyPhraseMatch, todoContentHash, stringSimilarity } from './utils/index.js';
import { MAX_LINE_BUFFER_SIZE } from './config/buffer-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
*/
const COMMON_COMPLETION_PHRASES = new Set([
'DONE', 'COMPLETE', 'FINISHED', 'OK', 'YES', 'TRUE', 'SUCCESS',
'READY', 'COMPLETED', 'PASSED', 'END', 'STOP', 'EXIT',
'DONE',
'COMPLETE',
'FINISHED',
'OK',
'YES',
'TRUE',
'SUCCESS',
'READY',
'COMPLETED',
'PASSED',
'END',
'STOP',
'EXIT',
]);
/**
@@ -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"
* 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
@@ -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"
* 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 ----------
@@ -1241,7 +1249,7 @@ export class RalphTracker extends EventEmitter {
// Check if all todos are complete (adds 20 points)
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;
score += 20;
}
@@ -1262,10 +1270,12 @@ export class RalphTracker extends EventEmitter {
if (context) {
const lowerContext = context.toLowerCase();
// Deduct points if phrase appears in prompt-like context
if (lowerContext.includes('output:') ||
if (
lowerContext.includes('output:') ||
lowerContext.includes('completion phrase') ||
lowerContext.includes('output exactly') ||
lowerContext.includes('when done')) {
lowerContext.includes('when done')
) {
signals.contextAppropriate = false;
score -= 20;
} else {
@@ -1335,7 +1345,6 @@ export class RalphTracker extends EventEmitter {
* Use this when the caller has already stripped ANSI to avoid redundant regex work.
*/
processCleanData(cleanData: string): void {
// If tracker is disabled, only check for patterns that should auto-enable it
if (!this._loopState.enabled) {
// Don't auto-enable if explicitly disabled by user setting
@@ -1397,13 +1406,20 @@ export class RalphTracker extends EventEmitter {
// This avoids 12 regex tests on every PTY chunk (the common case).
if (
!data.includes('<') && // <promise>, TodoWrite
!data.includes('ralph') && !data.includes('Ralph') &&
!data.includes('Todo') && !data.includes('todo') &&
!data.includes('Iteration') && !data.includes('[') &&
!data.includes('\u2610') && !data.includes('\u2612') && // ☐ ☒
!data.includes('ralph') &&
!data.includes('Ralph') &&
!data.includes('Todo') &&
!data.includes('todo') &&
!data.includes('Iteration') &&
!data.includes('[') &&
!data.includes('\u2610') &&
!data.includes('\u2612') && // ☐ ☒
!data.includes('\u2714') && // ✔
!data.includes('Loop') && !data.includes('complete') &&
!data.includes('COMPLETE') && !data.includes('Done') && !data.includes('DONE')
!data.includes('Loop') &&
!data.includes('complete') &&
!data.includes('COMPLETE') &&
!data.includes('Done') &&
!data.includes('DONE')
) {
return false;
}
@@ -1677,8 +1693,8 @@ export class RalphTracker extends EventEmitter {
// Avoid false positives: don't trigger on prompt context
const isNotInPromptContext = !line.includes('<promise>') && !line.includes('output:');
// Also avoid triggering on "completion phrase is X" explanatory text
const isNotExplanation = !line.toLowerCase().includes('completion phrase') &&
!line.toLowerCase().includes('output exactly');
const isNotExplanation =
!line.toLowerCase().includes('completion phrase') && !line.toLowerCase().includes('output exactly');
if (isNotInPromptContext && isNotExplanation) {
this.handleBareCompletionPhrase(expectedPhrase);
@@ -1786,7 +1802,7 @@ export class RalphTracker extends EventEmitter {
if (matchedPhrase) {
// 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.
// First occurrence (count=1) is the prompt echo — not actual completion.
if (canonicalCount >= 2 || this._loopState.active) {
@@ -1862,7 +1878,7 @@ export class RalphTracker extends EventEmitter {
* @fires phraseValidationWarning - When a risky phrase is detected
*/
private validateCompletionPhrase(phrase: string): void {
const normalized = phrase.toUpperCase().replace(/[\s_\-\.]+/g, '');
const normalized = phrase.toUpperCase().replace(/[\s_\-.]+/g, '');
// Generate a suggested unique phrase
const uniqueSuffix = Date.now().toString(36).slice(-4).toUpperCase();
@@ -1870,7 +1886,9 @@ export class RalphTracker extends EventEmitter {
// Check for common phrases
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', {
phrase,
reason: 'common',
@@ -1881,7 +1899,9 @@ export class RalphTracker extends EventEmitter {
// Check for short phrases
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', {
phrase,
reason: 'short',
@@ -1892,7 +1912,9 @@ export class RalphTracker extends EventEmitter {
// Check for numeric-only phrases
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', {
phrase,
reason: 'numeric',
@@ -1975,10 +1997,12 @@ export class RalphTracker extends EventEmitter {
// P1-004: Reset circuit breaker on successful iteration progress
// If we're making progress, the loop is healthy
if (this._circuitBreaker.state === 'HALF_OPEN' ||
if (
this._circuitBreaker.state === 'HALF_OPEN' ||
this._circuitBreaker.consecutiveNoProgress > 0 ||
this._circuitBreaker.consecutiveSameError > 0 ||
this._circuitBreaker.consecutiveTestsFailure > 0) {
this._circuitBreaker.consecutiveTestsFailure > 0
) {
this._circuitBreaker.consecutiveNoProgress = 0;
this._circuitBreaker.consecutiveSameError = 0;
this._circuitBreaker.lastProgressIteration = currentIter;
@@ -2106,7 +2130,7 @@ export class RalphTracker extends EventEmitter {
const content = match[2].trim();
// 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;
// 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) {
const taskNum = parseInt(match[1], 10);
const statusStr = match[2].trim();
const status: RalphTodoStatus = statusStr === 'completed' ? 'completed'
: statusStr === 'in progress' ? 'in_progress'
: 'pending';
const status: RalphTodoStatus =
statusStr === 'completed' ? 'completed' : statusStr === 'in progress' ? 'in_progress' : 'pending';
const content = this._taskNumberToContent.get(taskNum);
if (content) {
this.upsertTodo(content, status);
@@ -2172,7 +2195,7 @@ export class RalphTracker extends EventEmitter {
while ((match = TODO_PLAIN_CHECKMARK_PATTERN.exec(line)) !== null) {
const content = match[1].trim();
// 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 (content.length < 5) continue;
// Skip status/created/updated prefixed content (already handled above)
@@ -2507,7 +2530,7 @@ export class RalphTracker extends EventEmitter {
if (normalized.length < 30) {
threshold = 0.95; // Very strict for short strings
} else if (normalized.length < 60) {
threshold = 0.90; // Strict for medium strings
threshold = 0.9; // Strict for medium strings
} else {
threshold = TODO_SIMILARITY_THRESHOLD; // 0.85 for longer strings
}
@@ -2563,14 +2586,7 @@ export class RalphTracker extends EventEmitter {
];
// Moderate: Bugs, features, enhancements
const moderatePatterns = [
/\bbug\b/,
/\bfeature\b/,
/\benhance(?:ment)?\b/,
/\bimplement\b/,
/\badd\b/,
/\bfix\b/,
];
const moderatePatterns = [/\bbug\b/, /\bfeature\b/, /\benhance(?:ment)?\b/, /\bimplement\b/, /\badd\b/, /\bfix\b/];
for (const pattern of complexPatterns) {
if (pattern.test(lower)) return 'complex';
@@ -2671,9 +2687,9 @@ export class RalphTracker extends EventEmitter {
public getTodoProgress(): RalphTodoProgress {
const todos = Array.from(this._todos.values());
const total = todos.length;
const completed = todos.filter(t => t.status === 'completed').length;
const inProgress = todos.filter(t => t.status === 'in_progress').length;
const pending = todos.filter(t => t.status === 'pending').length;
const completed = todos.filter((t) => t.status === 'completed').length;
const inProgress = todos.filter((t) => t.status === 'in_progress').length;
const pending = todos.filter((t) => t.status === 'pending').length;
const percentComplete = total > 0 ? Math.round((completed / total) * 100) : 0;
@@ -3068,7 +3084,9 @@ export class RalphTracker extends EventEmitter {
if (!Number.isNaN(value) && value >= 0) {
block.tasksCompletedThisLoop = value;
} 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;
}
@@ -3104,7 +3122,9 @@ export class RalphTracker extends EventEmitter {
if (['IMPLEMENTATION', 'TESTING', 'DOCUMENTATION', 'REFACTORING'].includes(value)) {
block.workType = value as RalphWorkType;
} else {
parseErrors.push(`Invalid WORK_TYPE value: "${value}". Expected: IMPLEMENTATION, TESTING, DOCUMENTATION, or REFACTORING`);
parseErrors.push(
`Invalid WORK_TYPE value: "${value}". Expected: IMPLEMENTATION, TESTING, DOCUMENTATION, or REFACTORING`
);
}
matched = true;
}
@@ -3126,7 +3146,7 @@ export class RalphTracker extends EventEmitter {
// Track unknown fields for debugging (only if looks like a field)
if (!matched && trimmedLine.includes(':')) {
const fieldName = trimmedLine.split(':')[0].trim().toUpperCase();
if (fieldName && !['#', '//'].some(c => fieldName.startsWith(c))) {
if (fieldName && !['#', '//'].some((c) => fieldName.startsWith(c))) {
unknownFields.push(fieldName);
}
}
@@ -3221,11 +3241,7 @@ export class RalphTracker extends EventEmitter {
* @param status - Overall status from RALPH_STATUS
* @fires circuitBreakerUpdate - If state changes
*/
private updateCircuitBreaker(
hasProgress: boolean,
testsStatus: RalphTestsStatus,
status: RalphStatusValue
): void {
private updateCircuitBreaker(hasProgress: boolean, testsStatus: RalphTestsStatus, status: RalphStatusValue): void {
const prevState = this._circuitBreaker.state;
if (hasProgress) {
@@ -3319,7 +3335,11 @@ export class RalphTracker extends EventEmitter {
/**
* Get cumulative stats from status blocks.
*/
get cumulativeStats(): { filesModified: number; tasksCompleted: number; completionIndicators: number } {
get cumulativeStats(): {
filesModified: number;
tasksCompleted: number;
completionIndicators: number;
} {
return {
filesModified: this._totalFilesModified,
tasksCompleted: this._totalTasksCompleted,
@@ -3456,7 +3476,7 @@ export class RalphTracker extends EventEmitter {
const tasksHeaderPattern = /^##\s*Tasks/i;
// Pattern for todo items
const todoPattern = /^-\s*\[([ x\-])\]\s*(.+)$/;
const todoPattern = /^-\s*\[([ x-])\]\s*(.+)$/;
let inCompletedSection = false;
@@ -3504,7 +3524,7 @@ export class RalphTracker extends EventEmitter {
}
// 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);
newTodos.push({
@@ -3535,7 +3555,8 @@ export class RalphTracker extends EventEmitter {
* Initialize plan tasks from generated plan items.
* Called when wizard generates a new plan.
*/
initializePlanTasks(items: Array<{
initializePlanTasks(
items: Array<{
id?: string;
content: string;
priority?: 'P0' | 'P1' | 'P2' | null;
@@ -3545,7 +3566,8 @@ export class RalphTracker extends EventEmitter {
tddPhase?: TddPhase;
pairedWith?: string;
complexity?: 'low' | 'medium' | 'high';
}>): void {
}>
): void {
// Save current plan to history before replacing
if (this._planTasks.size > 0) {
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.
*/
updatePlanTask(taskId: string, update: {
updatePlanTask(
taskId: string,
update: {
status?: PlanTaskStatus;
error?: string;
incrementAttempts?: boolean;
}): { success: boolean; task?: EnhancedPlanTask; error?: string } {
}
): { success: boolean; task?: EnhancedPlanTask; error?: string } {
const task = this._planTasks.get(taskId);
if (!task) {
return { success: false, error: 'Task not found' };
@@ -3635,7 +3660,7 @@ export class RalphTracker extends EventEmitter {
for (const [_, task] of this._planTasks) {
if (task.dependencies.includes(completedTaskId)) {
// 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);
return dep && dep.status === 'completed';
});
@@ -3653,8 +3678,7 @@ export class RalphTracker extends EventEmitter {
*/
private _checkForCheckpoint(): void {
const currentIteration = this._loopState.cycleCount;
if (this._checkpointIterations.includes(currentIteration) &&
currentIteration > this._lastCheckpointIteration) {
if (this._checkpointIterations.includes(currentIteration) && currentIteration > this._lastCheckpointIteration) {
this._lastCheckpointIteration = currentIteration;
const checkpoint = this.generateCheckpointReview();
this.emit('planCheckpoint', checkpoint);
@@ -3669,17 +3693,17 @@ export class RalphTracker extends EventEmitter {
const summary = {
total: tasks.length,
completed: tasks.filter(t => t.status === 'completed').length,
failed: tasks.filter(t => t.status === 'failed').length,
blocked: tasks.filter(t => t.status === 'blocked').length,
pending: tasks.filter(t => t.status === 'pending').length,
inProgress: tasks.filter(t => t.status === 'in_progress').length,
completed: tasks.filter((t) => t.status === 'completed').length,
failed: tasks.filter((t) => t.status === 'failed').length,
blocked: tasks.filter((t) => t.status === 'blocked').length,
pending: tasks.filter((t) => t.status === 'pending').length,
inProgress: tasks.filter((t) => t.status === 'in_progress').length,
};
// Find stuck tasks (3+ attempts or blocked)
const stuckTasks = tasks
.filter(t => t.attempts >= 3 || t.status === 'blocked')
.map(t => ({
.filter((t) => t.attempts >= 3 || t.status === 'blocked')
.map((t) => ({
id: t.id,
content: t.content,
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.');
}
const progressPercent = summary.total > 0
? Math.round((summary.completed / summary.total) * 100)
: 0;
const progressPercent = summary.total > 0 ? Math.round((summary.completed / summary.total) * 100) : 0;
if (progressPercent < 20 && this._loopState.cycleCount > 10) {
recommendations.push('Progress is slow. Consider simplifying tasks or reviewing dependencies.');
}
@@ -3749,7 +3771,7 @@ export class RalphTracker extends EventEmitter {
summary: string;
stats: { total: number; completed: number; failed: number };
}> {
return this._planHistory.map(h => {
return this._planHistory.map((h) => {
const tasks = Array.from(h.tasks.values());
return {
version: h.version,
@@ -3757,8 +3779,8 @@ export class RalphTracker extends EventEmitter {
summary: h.summary,
stats: {
total: tasks.length,
completed: tasks.filter(t => t.status === 'completed').length,
failed: tasks.filter(t => t.status === 'failed').length,
completed: tasks.filter((t) => t.status === 'completed').length,
failed: tasks.filter((t) => t.status === 'failed').length,
},
};
});
@@ -3767,8 +3789,12 @@ export class RalphTracker extends EventEmitter {
/**
* Rollback to a previous plan version.
*/
rollbackToVersion(version: number): { success: boolean; plan?: EnhancedPlanTask[]; error?: string } {
const historyEntry = this._planHistory.find(h => h.version === version);
rollbackToVersion(version: number): {
success: boolean;
plan?: EnhancedPlanTask[];
error?: string;
} {
const historyEntry = this._planHistory.find((h) => h.version === version);
if (!historyEntry) {
return { success: false, error: `Version ${version} not found in history` };
}
@@ -3807,7 +3833,7 @@ export class RalphTracker extends EventEmitter {
// Generate unique ID
const existingIds = Array.from(this._planTasks.keys());
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')}`;
while (this._planTasks.has(id)) {
counter++;
@@ -3850,8 +3876,7 @@ export class RalphTracker extends EventEmitter {
*/
isCheckpointDue(): boolean {
const currentIteration = this._loopState.cycleCount;
return this._checkpointIterations.includes(currentIteration) &&
currentIteration > this._lastCheckpointIteration;
return this._checkpointIterations.includes(currentIteration) && currentIteration > this._lastCheckpointIteration;
}
/**
+144 -67
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 type { TeamWatcher } from './team-watcher.js';
import { BufferAccumulator } from './utils/buffer-accumulator.js';
import {
ANSI_ESCAPE_PATTERN_SIMPLE,
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 { ANSI_ESCAPE_PATTERN_SIMPLE, 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 {
RespawnCycleMetrics,
RespawnAggregateMetrics,
@@ -737,7 +730,8 @@ export class RespawnController extends EventEmitter {
// ========== Timer Tracking for UI Countdown 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) */
private recentActions: ActionLogEntry[] = [];
@@ -829,15 +823,55 @@ export class RespawnController extends EventEmitter {
* Note: ✻ and ✽ removed - they appear in completion messages too.
*/
private readonly WORKING_PATTERNS = [
'Thinking', 'Writing', 'Reading', 'Running', 'Searching',
'Editing', 'Creating', 'Deleting', '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
'Thinking',
'Writing',
'Reading',
'Running',
'Searching',
'Editing',
'Creating',
'Deleting',
'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
];
/**
@@ -1518,7 +1552,9 @@ export class RespawnController extends EventEmitter {
this.setState('watching');
} else {
// 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();
}
return;
@@ -1526,7 +1562,7 @@ export class RespawnController extends EventEmitter {
}
// 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) {
this.promptDetected = true;
this.workingDetected = false;
@@ -1831,11 +1867,16 @@ export class RespawnController extends EventEmitter {
if (this.stuckRecoveryCount < this.config.maxStuckRecoveries) {
this.stuckRecoveryCount++;
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.handleStuckStateRecovery();
} 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`);
}
return;
@@ -1950,12 +1991,7 @@ export class RespawnController extends EventEmitter {
* Start a tracked timer with UI countdown support.
* Emits timerStarted event and tracks the timer for UI display.
*/
private startTrackedTimer(
name: string,
durationMs: number,
callback: () => void,
reason?: string
): NodeJS.Timeout {
private startTrackedTimer(name: string, durationMs: number, callback: () => void, reason?: string): NodeJS.Timeout {
const now = Date.now();
const endsAt = now + durationMs;
@@ -1989,7 +2025,7 @@ export class RespawnController extends EventEmitter {
*/
getActiveTimers(): ActiveTimerInfo[] {
const now = Date.now();
return Array.from(this.activeTimers.values()).map(t => ({
return Array.from(this.activeTimers.values()).map((t) => ({
name: t.name,
remainingMs: Math.max(0, t.endsAt - now),
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)
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 (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;
}
@@ -2207,7 +2245,9 @@ export class RespawnController extends EventEmitter {
// Get the terminal buffer for analysis
const buffer = this.terminalBuffer.value;
this.aiChecker.check(buffer).then((result) => {
this.aiChecker
.check(buffer)
.then((result) => {
// If state changed while checking (e.g., cancelled), ignore result
if (this._state !== 'ai_checking') {
this.log(`AI check result ignored (state is now ${this._state})`);
@@ -2251,7 +2291,8 @@ export class RespawnController extends EventEmitter {
this.startNoOutputTimer();
this.startPreFilterTimer();
}
}).catch((err) => {
})
.catch((err) => {
// Validate this is the error for the current check
if (this._currentAiCheckId !== checkId) {
return; // Stale check, ignore error
@@ -2351,7 +2392,9 @@ export class RespawnController extends EventEmitter {
// Stage 2: AI confirmation (if enabled and available)
if (this.config.aiPlanCheckEnabled && this.planChecker.status !== 'disabled') {
if (this.planChecker.isOnCooldown()) {
this.log(`Skipping auto-accept: plan checker on cooldown (${Math.ceil(this.planChecker.getCooldownRemainingMs() / 1000)}s remaining)`);
this.log(
`Skipping auto-accept: plan checker on cooldown (${Math.ceil(this.planChecker.getCooldownRemainingMs() / 1000)}s remaining)`
);
return;
}
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.
const selectorIndex = stripped.lastIndexOf(selectorMatch[0]);
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;
return true;
@@ -2416,7 +2459,9 @@ export class RespawnController extends EventEmitter {
this.logAction('plan-check', 'Spawning AI plan checker');
this.emit('planCheckStarted');
this.planChecker.check(buffer).then((result) => {
this.planChecker
.check(buffer)
.then((result) => {
// Discard stale result if new output arrived during check
if (this.lastOutputTime > this.planCheckStartTime) {
this.logAction('plan-check', 'Result discarded (output arrived during check)');
@@ -2441,7 +2486,8 @@ export class RespawnController extends EventEmitter {
this.emit('planCheckFailed', result.reasoning);
this.logAction('plan-check', `Error: ${result.reasoning}`);
}
}).catch((err) => {
})
.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}`);
const status = this.getDetectionStatus();
this.log(`Detection status: confidence=${status.confidenceLevel}%, ` +
this.log(
`Detection status: confidence=${status.confidenceLevel}%, ` +
`completion=${status.completionMessageDetected}, ` +
`silent=${status.outputSilent}, ` +
`tokensStable=${status.tokensStable}, ` +
`noWorking=${status.workingPatternsAbsent}`);
`noWorking=${status.workingPatternsAbsent}`
);
// ========== Agent Teams Integration ==========
// 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);
this.log(`Respawn blocked - ${count} active teammate(s) working`);
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.startNoOutputTimer();
this.startPreFilterTimer();
@@ -2792,7 +2843,10 @@ export class RespawnController extends EventEmitter {
if (circuitBreaker.state === 'OPEN') {
this.log(`Respawn blocked - 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');
// Don't restart timers - wait for manual reset or circuit breaker resolution
return;
@@ -2804,7 +2858,10 @@ export class RespawnController extends EventEmitter {
if (statusBlock?.exitSignal) {
this.log(`Respawn paused - RALPH_STATUS EXIT_SIGNAL=true`);
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');
// Don't restart timers - loop is complete
return;
@@ -2814,7 +2871,10 @@ export class RespawnController extends EventEmitter {
if (statusBlock?.status === 'BLOCKED') {
this.log(`Respawn blocked - RALPH_STATUS reports BLOCKED`);
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');
return;
}
@@ -2846,7 +2906,10 @@ export class RespawnController extends EventEmitter {
if (this.session.status === 'error') {
this.log('Skipping respawn cycle - session is in 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');
return;
}
@@ -3052,9 +3115,13 @@ export class RespawnController extends EventEmitter {
this.config = { ...this.config, ...filteredConfig };
// Sync AI checker config if relevant fields changed
if (config.aiIdleCheckEnabled !== undefined || config.aiIdleCheckModel !== undefined ||
config.aiIdleCheckMaxContext !== undefined || config.aiIdleCheckTimeoutMs !== undefined ||
config.aiIdleCheckCooldownMs !== undefined) {
if (
config.aiIdleCheckEnabled !== undefined ||
config.aiIdleCheckModel !== undefined ||
config.aiIdleCheckMaxContext !== undefined ||
config.aiIdleCheckTimeoutMs !== undefined ||
config.aiIdleCheckCooldownMs !== undefined
) {
this.aiChecker.updateConfig({
enabled: this.config.aiIdleCheckEnabled,
model: this.config.aiIdleCheckModel,
@@ -3065,9 +3132,13 @@ export class RespawnController extends EventEmitter {
}
// Sync plan checker config if relevant fields changed
if (config.aiPlanCheckEnabled !== undefined || config.aiPlanCheckModel !== undefined ||
config.aiPlanCheckMaxContext !== undefined || config.aiPlanCheckTimeoutMs !== undefined ||
config.aiPlanCheckCooldownMs !== undefined) {
if (
config.aiPlanCheckEnabled !== undefined ||
config.aiPlanCheckModel !== undefined ||
config.aiPlanCheckMaxContext !== undefined ||
config.aiPlanCheckTimeoutMs !== undefined ||
config.aiPlanCheckCooldownMs !== undefined
) {
this.planChecker.updateConfig({
enabled: this.config.aiPlanCheckEnabled,
model: this.config.aiPlanCheckModel,
@@ -3276,7 +3347,7 @@ export class RespawnController extends EventEmitter {
const now = Date.now();
const metrics: RespawnCycleMetrics = {
...this.currentCycleMetrics as RespawnCycleMetrics,
...(this.currentCycleMetrics as RespawnCycleMetrics),
completedAt: now,
durationMs: now - (this.currentCycleMetrics.startedAt ?? now),
outcome,
@@ -3299,7 +3370,9 @@ export class RespawnController extends EventEmitter {
// Clear current cycle
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
const durations = this.recentCycleMetrics.map(m => m.durationMs);
const idleTimes = this.recentCycleMetrics.map(m => m.idleDetectionMs);
const durations = this.recentCycleMetrics.map((m) => m.durationMs);
const idleTimes = this.recentCycleMetrics.map((m) => m.idleDetectionMs);
if (durations.length > 0) {
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
agg.successRate = agg.totalCycles > 0
? Math.round((agg.successfulCycles / agg.totalCycles) * 100)
: 100;
agg.successRate = agg.totalCycles > 0 ? Math.round((agg.successfulCycles / agg.totalCycles) * 100) : 100;
agg.lastUpdatedAt = Date.now();
}
@@ -3392,10 +3463,10 @@ export class RespawnController extends EventEmitter {
// Weighted average (cycle success is most important)
const weights = {
cycleSuccess: 0.35,
circuitBreaker: 0.20,
iterationProgress: 0.20,
circuitBreaker: 0.2,
iterationProgress: 0.2,
aiChecker: 0.15,
stuckRecovery: 0.10,
stuckRecovery: 0.1,
};
const score = Math.round(
@@ -3446,10 +3517,14 @@ export class RespawnController extends EventEmitter {
const cb = tracker.circuitBreakerStatus;
switch (cb.state) {
case 'CLOSED': return 100;
case 'HALF_OPEN': return 50;
case 'OPEN': return 0;
default: return 100;
case 'CLOSED':
return 100;
case 'HALF_OPEN':
return 50;
case 'OPEN':
return 0;
default:
return 100;
}
}
@@ -3527,8 +3602,10 @@ export class RespawnController extends EventEmitter {
status: HealthStatus,
components: RalphLoopHealthScore['components']
): string {
const lowest = Object.entries(components).reduce((min, [key, val]) =>
val < min.val ? { key, val } : min, { key: '', val: 100 });
const lowest = Object.entries(components).reduce((min, [key, val]) => (val < min.val ? { key, val } : min), {
key: '',
val: 100,
});
if (status === 'excellent') {
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
if (oldState && oldState !== newState) {
this.stats.stateTransitions++;
this.addEvent(
'respawn_state_change',
'info',
`State: ${oldState} → ${newState}`,
details,
{ from: oldState, to: newState }
);
this.addEvent('respawn_state_change', 'info', `State: ${oldState} → ${newState}`, details, {
from: oldState,
to: newState,
});
}
// Update state tracking
@@ -214,7 +211,11 @@ export class RunSummaryTracker {
'info',
`Token milestone: ${this.formatTokens(currentMilestone)}`,
`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.
*/
recordRalphCompletion(phrase: string): void {
this.addEvent(
'ralph_completion',
'success',
'Ralph completion detected',
`Phrase: ${phrase}`,
{ phrase }
);
this.addEvent('ralph_completion', 'success', 'Ralph completion detected', `Phrase: ${phrase}`, {
phrase,
});
}
/**
@@ -278,9 +275,7 @@ export class RunSummaryTracker {
*/
recordHookEvent(eventType: string, data?: Record<string, unknown>): void {
const severity: RunSummaryEventSeverity =
eventType === 'stop' ? 'warning' :
eventType === 'permission_prompt' ? 'info' :
'info';
eventType === 'stop' ? 'warning' : eventType === 'permission_prompt' ? 'info' : 'info';
this.addEvent(
'hook_event',
@@ -311,13 +306,10 @@ export class RunSummaryTracker {
* Record session started.
*/
recordSessionStarted(mode: string, workingDir: string): void {
this.addEvent(
'session_started',
'success',
'Session started',
`Mode: ${mode}, Dir: ${workingDir}`,
{ mode, workingDir }
);
this.addEvent('session_started', 'success', '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);
let entries: LifecycleEntry[] = [];
const entries: LifecycleEntry[] = [];
// Parse in reverse (newest first) for efficiency with limit
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
// Define unlock first to ensure it's always in scope before promise assignment
let unlock!: () => void;
const lockPromise = new Promise<void>(resolve => {
const lockPromise = new Promise<void>((resolve) => {
unlock = resolve;
});
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.
*/
async stopAllSessions(): Promise<void> {
const stopPromises = Array.from(this.sessions.keys()).map((id) =>
this.stopSession(id)
);
const stopPromises = Array.from(this.sessions.keys()).map((id) => this.stopSession(id));
const results = await Promise.allSettled(stopPromises);
// Log any failures but don't throw - best effort cleanup
for (const result of results) {
@@ -292,4 +290,3 @@ export function getSessionManager(): SessionManager {
}
return managerInstance;
}
+149 -69
View File
@@ -18,19 +18,26 @@
import { EventEmitter } from 'node:events';
import { v4 as uuidv4 } from 'uuid';
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 { TaskTracker, type BackgroundTask } from './task-tracker.js';
import { RalphTracker } from './ralph-tracker.js';
import { BashToolParser } from './bash-tool-parser.js';
import { BufferAccumulator } from './utils/buffer-accumulator.js';
import { LRUMap } from './utils/lru-map.js';
import {
ANSI_ESCAPE_PATTERN_FULL,
TOKEN_PATTERN,
SPINNER_PATTERN,
MAX_SESSION_TOKENS,
} from './utils/index.js';
import { ANSI_ESCAPE_PATTERN_FULL, TOKEN_PATTERN, SPINNER_PATTERN, MAX_SESSION_TOKENS } from './utils/index.js';
import {
MAX_TERMINAL_BUFFER_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)
// ^[[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;
// 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)
/** 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])+/;
/** Pattern to match Ctrl+L (form feed) characters */
// eslint-disable-next-line no-control-regex
const CTRL_L_PATTERN = /\x0c/g;
/** Pattern to split by newlines (CR or LF) */
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
* @returns Promise that resolves/rejects with the original result or timeout error
*/
function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
operation: string
): Promise<T> {
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, operation: string): Promise<T> {
let timeoutId: NodeJS.Timeout;
const timeoutPromise = new Promise<never>((_, reject) => {
@@ -193,7 +199,12 @@ export interface SessionEvents {
/** Active Bash tools list updated */
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
/** 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)
@@ -356,7 +367,9 @@ export class Session extends EventEmitter {
// Task descriptions parsed from terminal output (e.g., "Explore(Description)")
// Used to correlate with SubagentWatcher discoveries for better window titles
// 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)
// Accumulates clean data between processing windows to avoid running regex on every chunk
@@ -365,7 +378,8 @@ export class Session extends EventEmitter {
private _expensiveProcessTimer: NodeJS.Timeout | null = null;
private static readonly EXPENSIVE_PROCESS_INTERVAL_MS = 150; // Process at most every 150ms
constructor(config: Partial<SessionConfig> & {
constructor(
config: Partial<SessionConfig> & {
workingDir: string;
mode?: SessionMode;
name?: string;
@@ -384,7 +398,8 @@ export class Session extends EventEmitter {
allowedTools?: string;
/** OpenCode configuration (only for mode === 'opencode') */
openCodeConfig?: OpenCodeConfig;
}) {
}
) {
super();
this.setMaxListeners(25);
@@ -472,7 +487,6 @@ export class Session extends EventEmitter {
this._bashToolParser.on('toolStart', this._bashToolHandlers.toolStart);
this._bashToolParser.on('toolEnd', this._bashToolHandlers.toolEnd);
this._bashToolParser.on('toolsUpdate', this._bashToolHandlers.toolsUpdate);
}
get status(): SessionStatus {
@@ -673,17 +687,23 @@ export class Session extends EventEmitter {
restoreTokens(inputTokens: number, outputTokens: number, totalCost: number): void {
// Sanity check: reject absurdly large individual values
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;
}
// Check token sum doesn't overflow 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;
}
// Reject negative values
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;
}
@@ -722,7 +742,9 @@ export class Session extends EventEmitter {
if (threshold !== undefined) {
// Validate threshold bounds
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;
} else {
this._autoClearThreshold = threshold;
@@ -747,7 +769,9 @@ export class Session extends EventEmitter {
if (threshold !== undefined) {
// Validate threshold bounds
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;
} else {
this._autoCompactThreshold = threshold;
@@ -911,7 +935,9 @@ export class Session extends EventEmitter {
this._lastActivityAt = Date.now();
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 (this._useMux && this._mux) {
@@ -928,16 +954,21 @@ export class Session extends EventEmitter {
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
const newPid = await this._mux.respawnPane({
sessionId: this.id, workingDir: this.workingDir, mode: this.mode,
niceConfig: this._niceConfig, model: this._model, claudeMode: this._claudeMode,
allowedTools: this._allowedTools, openCodeConfig: this._openCodeConfig,
sessionId: this.id,
workingDir: this.workingDir,
mode: this.mode,
niceConfig: this._niceConfig,
model: this._model,
claudeMode: this._claudeMode,
allowedTools: this._allowedTools,
openCodeConfig: this._openCodeConfig,
});
if (!newPid) {
console.error('[Session] Failed to respawn pane, will create new session');
needsNewSession = true;
} else {
// Wait a moment for the respawned process to fully start
await new Promise(resolve => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
}
}
@@ -948,9 +979,14 @@ export class Session extends EventEmitter {
} else {
// Create a new mux session
this._muxSession = await this._mux.createSession({
sessionId: this.id, workingDir: this.workingDir, mode: this.mode,
name: this._name, niceConfig: this._niceConfig, model: this._model,
claudeMode: this._claudeMode, allowedTools: this._allowedTools,
sessionId: this.id,
workingDir: this.workingDir,
mode: this.mode,
name: this._name,
niceConfig: this._niceConfig,
model: this._model,
claudeMode: this._claudeMode,
allowedTools: this._allowedTools,
openCodeConfig: this._openCodeConfig,
});
console.log('[Session] Created mux session:', this._muxSession.muxName);
@@ -967,8 +1003,16 @@ export class Session extends EventEmitter {
cols: 120,
rows: 40,
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
// 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
// Strip: cursor movement (\x1b[nA/B/C/D), positioning (\x1b[n;nH),
// clear screen (\x1b[2J), scroll region (\x1b[n;nr), and whitespace
this._terminalBuffer.set(
bufferValue.replace(LEADING_ANSI_WHITESPACE_PATTERN, '')
);
this._terminalBuffer.set(bufferValue.replace(LEADING_ANSI_WHITESPACE_PATTERN, ''));
// Signal client to refresh
this.emit('clearTerminal');
}
@@ -1081,9 +1123,7 @@ export class Session extends EventEmitter {
this.ptyProcess.onData((rawData: string) => {
// Filter out focus escape sequences and Ctrl+L (form feed)
const data = rawData
.replace(FOCUS_ESCAPE_FILTER, '')
.replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '').replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L
if (!data) return; // Skip if only filtered sequences
// 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
if (!this._isWorking) {
const cleanData = getCleanData();
if (cleanData.includes('Thinking') || cleanData.includes('Writing') ||
cleanData.includes('Reading') || cleanData.includes('Running')) {
if (
cleanData.includes('Thinking') ||
cleanData.includes('Writing') ||
cleanData.includes('Reading') ||
cleanData.includes('Running')
) {
this._isWorking = true;
this._status = 'busy';
this.emit('working');
@@ -1293,7 +1337,10 @@ export class Session extends EventEmitter {
// Use user's default shell or 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 (this._useMux && this._mux) {
@@ -1308,12 +1355,17 @@ export class Session extends EventEmitter {
let needsNewSession = false;
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
const newPid = await this._mux.respawnPane({ sessionId: this.id, 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) {
console.error('[Session] Failed to respawn pane, will create new session');
needsNewSession = true;
} 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);
} else {
// 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);
// No extra sleep — createSession() already waits for tmux readiness
}
@@ -1338,8 +1396,16 @@ export class Session extends EventEmitter {
cols: 120,
rows: 40,
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) {
console.error('[Session] Failed to spawn PTY for shell mux attachment:', spawnErr);
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
@@ -1482,14 +1548,13 @@ export class Session extends EventEmitter {
try {
// Spawn claude in a real PTY
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 = [
'-p',
'--verbose',
'--dangerously-skip-permissions',
'--output-format', 'stream-json',
];
const args = ['-p', '--verbose', '--dangerously-skip-permissions', '--output-format', 'stream-json'];
if (model) {
args.push('--model', model);
}
@@ -1517,7 +1582,10 @@ export class Session extends EventEmitter {
});
} catch (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;
}
@@ -1561,7 +1629,7 @@ export class Session extends EventEmitter {
this.rejectPromise = null;
// 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) {
this._status = 'idle';
@@ -1579,13 +1647,15 @@ export class Session extends EventEmitter {
} else {
this._status = 'idle';
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);
});
} catch (err) {
this._status = 'error';
reject(err);
@@ -1649,8 +1719,8 @@ export class Session extends EventEmitter {
// Extract Claude session ID from messages (can be in any message type)
// Support both sessionId (camelCase) and session_id (snake_case)
const msgSessionId = (msg as unknown as Record<string, unknown>).sessionId as string | undefined
?? msg.session_id;
const msgSessionId =
((msg as unknown as Record<string, unknown>).sessionId as string | undefined) ?? msg.session_id;
if (msgSessionId && !this._claudeSessionId) {
this._claudeSessionId = msgSessionId;
}
@@ -1689,7 +1759,10 @@ export class Session extends EventEmitter {
}
} catch (parseErr) {
// Not JSON, just regular output - this is expected for non-JSON lines
console.debug('[Session] Line not JSON (expected for text output):', parseErr instanceof Error ? parseErr.message : parseErr);
console.debug(
'[Session] Line not JSON (expected for text output):',
parseErr instanceof Error ? parseErr.message : parseErr
);
this._textOutput.append(line + '\n');
}
} else if (trimmed) {
@@ -1829,7 +1902,9 @@ export class Session extends EventEmitter {
// 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
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;
}
tokenCount *= 1000000;
@@ -1850,7 +1925,9 @@ export class Session extends EventEmitter {
// Safety: Reject suspiciously large jumps (max 100k per update)
const MAX_DELTA_PER_UPDATE = 100_000;
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;
}
@@ -1874,7 +1951,12 @@ export class Session extends EventEmitter {
if (this._cliInfoParsed) return;
// 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;
}
let changed = false;
@@ -1960,14 +2042,12 @@ export class Session extends EventEmitter {
if (this._isStopped) return;
// Send /compact command with optional prompt
const compactCmd = this._autoCompactPrompt
? `/compact ${this._autoCompactPrompt}\r`
: '/compact\r';
const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r';
await this.writeViaMux(compactCmd);
this.emit('autoCompact', {
tokens: totalTokens,
threshold: this._autoCompactThreshold,
prompt: this._autoCompactPrompt || undefined
prompt: this._autoCompactPrompt || undefined,
});
// 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> {
this._status = 'busy';
this._lastActivityAt = Date.now();
this.runPrompt(input).catch(err => {
this.runPrompt(input).catch((err) => {
const errorMsg = err instanceof Error ? err.message : String(err);
this.emit('error', errorMsg);
});
@@ -2260,7 +2340,7 @@ export class Session extends EventEmitter {
}
// 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
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 { homedir } from 'node:os';
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';
/** Debounce delay for batching state writes (ms) */
@@ -299,7 +308,11 @@ export class StateStore {
} catch (err) {
console.error('[StateStore] Failed to write state file:', err);
this.consecutiveSaveFailures++;
try { if (existsSync(tempPath)) unlinkSync(tempPath); } catch { /* ignore */ }
try {
if (existsSync(tempPath)) unlinkSync(tempPath);
} catch {
/* ignore */
}
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
console.error('[StateStore] Circuit breaker OPEN - writes failing repeatedly');
this.circuitBreakerOpen = true;
@@ -384,7 +397,10 @@ export class StateStore {
* @param activeSessionIds - Set of currently active session IDs
* @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 cleaned: Array<{ id: string; name?: string }> = [];
@@ -481,7 +497,9 @@ export class StateStore {
}
// Reject negative values
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;
}
@@ -505,7 +523,9 @@ export class StateStore {
* Returns aggregate stats combining global (deleted sessions) + active sessions.
* @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;
totalOutputTokens: number;
totalCost: number;
@@ -602,7 +622,7 @@ export class StateStore {
}
// 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) {
todayEntry = {
date: today,
@@ -617,10 +637,7 @@ export class StateStore {
// Accumulate tokens
todayEntry.inputTokens += inputTokens;
todayEntry.outputTokens += outputTokens;
todayEntry.estimatedCost = this.calculateEstimatedCost(
todayEntry.inputTokens,
todayEntry.outputTokens
);
todayEntry.estimatedCost = this.calculateEstimatedCost(todayEntry.inputTokens, todayEntry.outputTokens);
// Only increment session count for unique sessions
if (sessionId && !this.dailySessionIds.has(sessionId)) {
+69 -41
View File
@@ -77,7 +77,9 @@ export interface SubagentTranscriptEntry {
input_tokens?: number;
output_tokens?: number;
};
content: string | Array<{
content:
| string
| Array<{
type: 'text' | 'tool_use' | 'tool_result';
text?: string;
name?: string;
@@ -194,7 +196,7 @@ export class SubagentWatcher extends EventEmitter {
if (!description) return false;
// Filter out very short descriptions (likely internal or malformed)
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;
// 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
needsFullScan.push(info);
@@ -322,20 +324,35 @@ export class SubagentWatcher extends EventEmitter {
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
await Promise.all(pids.map(async (pid) => {
await Promise.all(
pids.map(async (pid) => {
let environ = '';
let cmdline = '';
try { environ = await readFile(`/proc/${pid}/environ`, 'utf8'); } catch { /* skip */ }
try { cmdline = await readFile(`/proc/${pid}/cmdline`, 'utf8'); } catch { /* skip */ }
try {
environ = await readFile(`/proc/${pid}/environ`, 'utf8');
} catch {
/* skip */
}
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
for (const [pid, procInfo] of result) {
@@ -567,9 +584,7 @@ export class SubagentWatcher extends EventEmitter {
*/
getSubagentsForSession(workingDir: string): SubagentInfo[] {
const projectHash = this.getProjectHash(workingDir);
return Array.from(this.agentInfo.values()).filter(
(info) => info.projectHash === projectHash
);
return Array.from(this.agentInfo.values()).filter((info) => info.projectHash === projectHash);
}
/**
@@ -815,7 +830,8 @@ export class SubagentWatcher extends EventEmitter {
} else if (content.type === 'text' && content.text) {
const text = content.text.trim();
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, ' ')}`);
}
}
@@ -860,7 +876,7 @@ export class SubagentWatcher extends EventEmitter {
const MAX_LEN = SMART_TITLE_MAX_LENGTH;
// 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 (title.length <= MAX_LEN) {
@@ -932,14 +948,18 @@ export class SubagentWatcher extends EventEmitter {
// Check cache first (covers burst of simultaneous agent discoveries)
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);
}
try {
// The parent session's transcript is at: ~/.claude/projects/{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 lines = content.split('\n').filter((l) => l.trim());
@@ -949,11 +969,7 @@ export class SubagentWatcher extends EventEmitter {
for (const line of lines) {
try {
const entry = JSON.parse(line);
if (
entry.type === 'user' &&
entry.toolUseResult?.agentId &&
entry.toolUseResult?.description
) {
if (entry.type === 'user' && entry.toolUseResult?.agentId && entry.toolUseResult?.description) {
descriptions.set(entry.toolUseResult.agentId, entry.toolUseResult.description);
}
} catch {
@@ -982,7 +998,11 @@ export class SubagentWatcher extends EventEmitter {
let lineCount = 0;
let resolved = false;
rl.on('line', (line) => {
if (resolved || lineCount >= 5) { rl.close(); stream.destroy(); return; }
if (resolved || lineCount >= 5) {
rl.close();
stream.destroy();
return;
}
lineCount++;
if (!line.trim()) return;
try {
@@ -1008,8 +1028,12 @@ export class SubagentWatcher extends EventEmitter {
// Skip malformed lines
}
});
rl.on('close', () => { if (!resolved) resolve(undefined); });
rl.on('error', () => { if (!resolved) resolve(undefined); });
rl.on('close', () => {
if (!resolved) resolve(undefined);
});
rl.on('error', () => {
if (!resolved) resolve(undefined);
});
});
} catch {
// Failed to read file
@@ -1021,7 +1045,11 @@ export class SubagentWatcher extends EventEmitter {
* Scan for all subagent directories (async to avoid blocking event loop)
*/
private async scanForSubagents(): Promise<void> {
try { await statAsync(CLAUDE_PROJECTS_DIR); } catch { return; }
try {
await statAsync(CLAUDE_PROJECTS_DIR);
} catch {
return;
}
try {
const projects = await readdir(CLAUDE_PROJECTS_DIR);
@@ -1123,7 +1151,12 @@ export class SubagentWatcher extends EventEmitter {
* @param sessionId Claude session ID
* @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;
const agentId = basename(filePath).replace('agent-', '').replace('.jsonl', '');
@@ -1192,9 +1225,11 @@ export class SubagentWatcher extends EventEmitter {
this.emit('subagent:discovered', info);
// Read existing content
this.tailFile(filePath, agentId, sessionId, 0).then((position) => {
this.tailFile(filePath, agentId, sessionId, 0)
.then((position) => {
this.filePositions.set(filePath, position);
}).catch((err) => {
})
.catch((err) => {
// Log but don't throw - non-critical background operation
console.warn(`[SubagentWatcher] Failed to read initial content for ${agentId}:`, err);
});
@@ -1269,12 +1304,7 @@ export class SubagentWatcher extends EventEmitter {
/**
* Tail a file from a specific position
*/
private async tailFile(
filePath: string,
agentId: string,
sessionId: string,
fromPosition: number
): Promise<number> {
private async tailFile(filePath: string, agentId: string, sessionId: string, fromPosition: number): Promise<number> {
return new Promise((resolve) => {
let position = fromPosition;
@@ -1337,11 +1367,7 @@ export class SubagentWatcher extends EventEmitter {
// Check if this is first user message and description is missing
if (info && !info.description && entry.type === 'user' && entry.message?.content) {
// First try parent transcript (most reliable)
let description = await this.extractDescriptionFromParentTranscript(
info.projectHash,
info.sessionId,
agentId
);
let description = await this.extractDescriptionFromParentTranscript(info.projectHash, info.sessionId, agentId);
// Fallback: extract smart title from the prompt content
if (!description) {
let text: string | undefined;
@@ -1378,7 +1404,9 @@ export class SubagentWatcher extends EventEmitter {
resultCount: entry.data.resultCount,
// Extract hook event info if present
hookEvent: entry.data.hookEvent,
hookName: entry.data.hookName || (entry.data.hookEvent && entry.data.tool_name
hookName:
entry.data.hookName ||
(entry.data.hookEvent && entry.data.tool_name
? `${entry.data.hookEvent}:${entry.data.tool_name}`
: undefined),
};
@@ -1531,8 +1559,8 @@ export class SubagentWatcher extends EventEmitter {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.filter(c => c.type === 'text' && c.text)
.map(c => c.text)
.filter((c) => c.type === 'text' && c.text)
.map((c) => c.text)
.join('\n');
}
return '';
+15 -7
View File
@@ -196,7 +196,9 @@ export class TaskQueue extends EventEmitter {
private validateDependencies(taskId: string, dependencies: string[]): void {
for (const depId of dependencies) {
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. */
getRunningTaskForSession(sessionId: string): Task | null {
return this.getAllTasks().find(
(t) => t.isRunning() && t.assignedSessionId === sessionId
) || null;
return this.getAllTasks().find((t) => t.isRunning() && t.assignedSessionId === sessionId) || null;
}
/** Gets counts of tasks by status (single-pass). */
getCount(): { total: number; pending: number; running: number; completed: number; failed: number } {
let pending = 0, running = 0, completed = 0, failed = 0;
getCount(): {
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()) {
if (task.isPending()) pending++;
else if (task.isRunning()) running++;
@@ -258,7 +267,6 @@ export class TaskQueue extends EventEmitter {
this.tasks.clear();
return count;
}
}
// 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.
* Capture group 1: Agent/task type name
*/
const LAUNCH_PATTERNS = [
/Launching\s+(\w+)\s+agent/i,
/Starting\s+(\w+)\s+task/i,
/Spawning\s+(\w+)\s+agent/i,
];
const LAUNCH_PATTERNS = [/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.
* Used as fallback when JSON parsing doesn't capture the result.
*/
const COMPLETE_PATTERNS = [
/Task\s+completed/i,
/Agent\s+finished/i,
/Background\s+task\s+done/i,
];
const COMPLETE_PATTERNS = [/Task\s+completed/i, /Agent\s+finished/i, /Background\s+task\s+done/i];
// ========== Type Definitions ==========
@@ -218,7 +210,10 @@ export class TaskTracker extends EventEmitter {
private taskStack: string[] = [];
/** 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.
@@ -316,7 +311,12 @@ export class TaskTracker extends EventEmitter {
const parentId = this.taskStack.length > 0 ? this.taskStack[this.taskStack.length - 1] : null;
// 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
this.cleanupOldPendingToolUses();
@@ -361,9 +361,7 @@ export class TaskTracker extends EventEmitter {
if (task) {
task.status = block.is_error ? 'failed' : 'completed';
task.endTime = Date.now();
task.output = typeof block.content === 'string'
? block.content
: JSON.stringify(block.content);
task.output = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
// Remove from stack
const stackIndex = this.taskStack.indexOf(toolUseId);
@@ -610,13 +608,21 @@ export class TaskTracker extends EventEmitter {
* - failed: Tasks that ended with errors
*/
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()) {
switch (task.status) {
case 'running': running++; break;
case 'completed': completed++; break;
case 'failed': failed++; break;
case 'running':
running++;
break;
case 'completed':
completed++;
break;
case 'failed':
failed++;
break;
default:
assertNever(task.status, `Unhandled BackgroundTask status: ${task.status}`);
}
+11 -15
View File
@@ -86,13 +86,13 @@ export class TeamWatcher extends EventEmitter {
getTeamTasks(teamName: string): TeamTask[] {
const tasks = this.teamTasks.get(teamName);
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 */
getActiveTaskCount(teamName: string): number {
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) */
@@ -108,9 +108,7 @@ export class TeamWatcher extends EventEmitter {
messages.push(...msgs);
}
}
return messages.sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
return messages.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
}
/** Check if a session has active teammates (for idle detection) */
@@ -119,7 +117,7 @@ export class TeamWatcher extends EventEmitter {
if (!team) return false;
// 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;
// Check if team has active (non-completed) tasks
@@ -132,11 +130,9 @@ export class TeamWatcher extends EventEmitter {
const team = this.teams.get(teamName);
if (!team) return [];
return team.members
.filter((m): m is TeamMember & { tmuxPaneId: string } =>
m.agentType !== 'team-lead' &&
!!m.tmuxPaneId &&
m.tmuxPaneId !== 'in-process'
return team.members.filter(
(m): m is TeamMember & { tmuxPaneId: string } =>
m.agentType !== 'team-lead' && !!m.tmuxPaneId && m.tmuxPaneId !== 'in-process'
);
}
@@ -144,7 +140,7 @@ export class TeamWatcher extends EventEmitter {
getActiveTeammateCount(sessionId: string): number {
const team = this.getTeamForSession(sessionId);
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 ==========
@@ -257,7 +253,7 @@ export class TeamWatcher extends EventEmitter {
let taskFiles: string[];
try {
taskFiles = (await readdir(teamTaskDir)).filter(f => f.endsWith('.json') && f !== '.lock');
taskFiles = (await readdir(teamTaskDir)).filter((f) => f.endsWith('.json') && f !== '.lock');
} catch {
continue;
}
@@ -285,7 +281,7 @@ export class TeamWatcher extends EventEmitter {
let inboxFiles: string[];
try {
inboxFiles = (await readdir(inboxDir)).filter(f => f.endsWith('.json'));
inboxFiles = (await readdir(inboxDir)).filter((f) => f.endsWith('.json'));
} catch {
continue;
}
@@ -314,7 +310,7 @@ export class TeamWatcher extends EventEmitter {
this.inboxCache.set(cacheKey, messages);
// 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) {
if (!prevTimestamps.has(msg.timestamp)) {
this.emit('inboxMessage', { teamName, member: memberName, message: msg });
+207 -143
View File
@@ -30,10 +30,25 @@ import { existsSync, readFileSync, mkdirSync } from 'node:fs';
import { writeFile, rename } from 'node:fs/promises';
import { dirname, join } from 'node:path';
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 { 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
import { findClaudeDir } from './utils/claude-cli-resolver.js';
@@ -103,11 +118,23 @@ function isValidMuxName(name: string): boolean {
* Prevents command injection via malformed paths.
*/
function isValidPath(path: string): boolean {
if (path.includes(';') || path.includes('&') || path.includes('|') ||
path.includes('$') || path.includes('`') || path.includes('(') ||
path.includes(')') || path.includes('{') || path.includes('}') ||
path.includes('<') || path.includes('>') || path.includes("'") ||
path.includes('"') || path.includes('\n') || path.includes('\r')) {
if (
path.includes(';') ||
path.includes('&') ||
path.includes('|') ||
path.includes('$') ||
path.includes('`') ||
path.includes('(') ||
path.includes(')') ||
path.includes('{') ||
path.includes('}') ||
path.includes('<') ||
path.includes('>') ||
path.includes("'") ||
path.includes('"') ||
path.includes('\n') ||
path.includes('\r')
) {
return false;
}
if (path.includes('..')) {
@@ -177,7 +204,7 @@ function buildSpawnCommand(options: {
}): string {
if (options.mode === 'claude') {
// 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}` : '';
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,
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>;
Object.assign(permConfig, existing);
permConfig.permission = { '*': 'allow' };
} catch { /* invalid JSON, use default permConfig */ }
} catch {
/* invalid JSON, use default permConfig */
}
}
jsonContent = JSON.stringify(permConfig);
} else if (config.configContent) {
@@ -247,7 +278,9 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi
timeout: EXEC_TIMEOUT_MS,
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');
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 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.)
const cleanEnv = { ...process.env };
delete cleanEnv.TMUX;
execSync(
`tmux new-session -ds "${muxName}" -c "${workingDir}" -x 120 -y 40`,
{ cwd: workingDir, timeout: EXEC_TIMEOUT_MS, stdio: 'ignore', env: cleanEnv }
);
execSync(`tmux new-session -ds "${muxName}" -c "${workingDir}" -x 120 -y 40`, {
cwd: workingDir,
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
env: cleanEnv,
});
// Set remain-on-exit now that the server is running — must be before respawn-pane
try {
execSync(`tmux set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS, stdio: 'ignore' });
} catch { /* Non-critical */ }
execSync(`tmux set-option -t "${muxName}" remain-on-exit on`, {
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
});
} catch {
/* Non-critical */
}
// For OpenCode: set sensitive env vars and config via tmux setenv
// (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)
execSync(
`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`,
{ timeout: EXEC_TIMEOUT_MS, stdio: 'ignore' }
);
execSync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
});
// 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.
// These configure UX niceties (no status bar, true color).
@@ -445,18 +492,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const configPromises: Promise<void>[] = [
// Disable tmux status bar — Codeman's web UI provides session info
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
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
if (!this.trueColorConfigured) {
configPromises.push(
execAsync(`tmux set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS })
.then(() => { this.trueColorConfigured = true; })
.catch(() => { /* Non-critical — colors limited to 256 */ })
.then(() => {
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)
let pid = this.getPanePid(muxName);
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);
}
if (!pid) {
@@ -507,10 +564,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
try {
const output = execSync(
`tmux display-message -t "${muxName}" -p '#{pane_pid}'`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
).trim();
const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_pid}'`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
const pid = parseInt(output, 10);
return Number.isNaN(pid) ? null : pid;
} catch {
@@ -533,10 +590,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (IS_TEST_MODE) return false;
if (!isValidMuxName(muxName)) return false;
try {
const output = execSync(
`tmux display-message -t "${muxName}" -p '#{pane_dead}'`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
).trim();
const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_dead}'`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
return output === '1';
} catch {
return false;
@@ -578,7 +635,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE');
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 cmd = wrapWithNice(baseCmd, config);
const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`;
@@ -590,12 +654,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
setOpenCodeConfigContent(muxName, openCodeConfig);
}
await execAsync(
`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`,
{ timeout: EXEC_TIMEOUT_MS }
);
await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
timeout: EXEC_TIMEOUT_MS,
});
// 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);
if (pid) session.pid = pid;
return pid;
@@ -628,7 +691,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
timeout: EXEC_TIMEOUT_MS,
}).trim();
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(...this.getChildPids(childPid));
}
@@ -655,14 +721,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const checkInterval = 100;
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) {
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) {
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);
for (const childPid of childPids) {
@@ -735,7 +801,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (this.isProcessAlive(currentPid)) {
try {
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)) {
process.kill(-currentPid, 'SIGKILL');
}
@@ -829,10 +895,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Discover unknown codeman sessions
try {
const output = execSync(
"tmux list-sessions -F '#{session_name}' 2>/dev/null || true",
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
).trim();
const output = execSync("tmux list-sessions -F '#{session_name}' 2>/dev/null || true", {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
for (const line of output.split('\n')) {
const sessionName = line.trim();
@@ -890,26 +956,26 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
try {
const psOutput = execSync(
`ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
).trim();
const psOutput = execSync(`ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).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;
try {
const childOutput = execSync(
`pgrep -P ${session.pid} | wc -l`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
).trim();
const childOutput = execSync(`pgrep -P ${session.pid} | wc -l`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
childCount = parseInt(childOutput, 10) || 0;
} catch {
// No children or command failed
}
return {
memoryMB: Math.round(rss / 1024 * 10) / 10,
memoryMB: Math.round((rss / 1024) * 10) / 10,
cpuPercent: Math.round(cpu * 10) / 10,
childCount,
updatedAt: Date.now(),
@@ -921,7 +987,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
async getSessionsWithStats(): Promise<MuxSessionWithStats[]> {
if (IS_TEST_MODE) {
return Array.from(this.sessions.values()).map(s => ({
return Array.from(this.sessions.values()).map((s) => ({
...s,
stats: { memoryMB: 0, cpuPercent: 0, childCount: 0, updatedAt: Date.now() },
}));
@@ -932,7 +998,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return [];
}
const sessionPids = sessions.map(s => s.pid);
const sessionPids = sessions.map((s) => s.pid);
const statsMap = new Map<number, ProcessStats>();
try {
@@ -941,7 +1007,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const pgrepOutput = execSync(
`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();
for (const line of pgrepOutput.split('\n')) {
@@ -950,8 +1019,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (!Number.isNaN(sessionPid)) {
const children = (childrenStr || '')
.split(',')
.map(s => parseInt(s.trim(), 10))
.filter(n => !Number.isNaN(n) && n > 0);
.map((s) => parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n) && n > 0);
descendantMap.set(sessionPid, children);
}
}
@@ -967,10 +1036,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Step 3: Single ps call
const pidArray = Array.from(allPids);
if (pidArray.length > 0) {
const psOutput = execSync(
`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
).trim();
const psOutput = execSync(`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
const processStats = new Map<number, { rss: number; cpu: number }>();
for (const line of psOutput.split('\n')) {
@@ -1002,7 +1071,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
statsMap.set(sessionPid, {
memoryMB: Math.round(totalRss / 1024 * 10) / 10,
memoryMB: Math.round((totalRss / 1024) * 10) / 10,
cpuPercent: Math.round(totalCpu * 10) / 10,
childCount: children.length,
updatedAt: Date.now(),
@@ -1011,7 +1080,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
} catch {
// 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);
return sessions.map((session, i) => ({
...session,
@@ -1019,7 +1088,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}));
}
return sessions.map(session => ({
return sessions.map((session) => ({
...session,
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> {
const session = this.sessions.get(sessionId);
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;
}
@@ -1154,7 +1225,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
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)) {
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
// single tmux invocation (via \;) causes Ink to interpret Enter as a newline
// character in the input buffer rather than as form submission.
await execAsync(
`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`,
{ timeout: EXEC_TIMEOUT_MS }
);
await new Promise(resolve => setTimeout(resolve, 50));
await execAsync(
`tmux send-keys -t "${session.muxName}" Enter`,
{ timeout: EXEC_TIMEOUT_MS }
);
await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
timeout: EXEC_TIMEOUT_MS,
});
await new Promise((resolve) => setTimeout(resolve, 50));
await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, {
timeout: EXEC_TIMEOUT_MS,
});
} else if (textPart) {
// Text only, no Enter
await execAsync(
`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`,
{ timeout: EXEC_TIMEOUT_MS }
);
await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
timeout: EXEC_TIMEOUT_MS,
});
} else if (hasCarriageReturn) {
// Enter only
await execAsync(
`tmux send-keys -t "${session.muxName}" Enter`,
{ timeout: EXEC_TIMEOUT_MS }
);
await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, {
timeout: EXEC_TIMEOUT_MS,
});
}
return true;
@@ -1215,10 +1284,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
try {
execSync(
`tmux set-option -t "${muxName}" mouse on`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
);
execSync(`tmux set-option -t "${muxName}" mouse on`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
console.log(`[TmuxManager] Mouse mode ON for ${muxName}`);
return true;
} catch (err) {
@@ -1239,10 +1308,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
try {
execSync(
`tmux set-option -t "${muxName}" mouse off`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
);
execSync(`tmux set-option -t "${muxName}" mouse off`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
console.log(`[TmuxManager] Mouse mode OFF for ${muxName}`);
return true;
} catch (err) {
@@ -1283,7 +1352,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
).trim();
return output.split('\n').map(line => {
return output
.split('\n')
.map((line) => {
const [paneId, indexStr, pidStr, widthStr, heightStr] = line.split(':');
return {
paneId,
@@ -1292,7 +1363,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
width: parseInt(widthStr, 10),
height: parseInt(heightStr, 10),
};
}).filter(p => !Number.isNaN(p.paneIndex));
})
.filter((p) => !Number.isNaN(p.paneIndex));
} catch {
return [];
}
@@ -1314,33 +1386,31 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
// Build target: sessionName.paneId (e.g., "codeman-abc12345.%1")
const target = paneTarget.startsWith('%')
? `${muxName}.${paneTarget}`
: `${muxName}.%${paneTarget}`;
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try {
const hasCarriageReturn = input.includes('\r');
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
if (textPart && hasCarriageReturn) {
execSync(
`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
);
execSync(
`tmux send-keys -t ${shellescape(target)} Enter`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
);
execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
execSync(`tmux send-keys -t ${shellescape(target)} Enter`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
} else if (textPart) {
execSync(
`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
);
execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
} else if (hasCarriageReturn) {
execSync(
`tmux send-keys -t ${shellescape(target)} Enter`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
);
execSync(`tmux send-keys -t ${shellescape(target)} Enter`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
}
return true;
@@ -1365,15 +1435,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return null;
}
const target = paneTarget.startsWith('%')
? `${muxName}.${paneTarget}`
: `${muxName}.%${paneTarget}`;
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try {
return execSync(
`tmux capture-pane -p -e -t ${shellescape(target)} -S -5000`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
);
return execSync(`tmux capture-pane -p -e -t ${shellescape(target)} -S -5000`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
} catch (err) {
console.error('[TmuxManager] Failed to capture pane buffer:', err);
return null;
@@ -1399,15 +1467,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return false;
}
const target = paneTarget.startsWith('%')
? `${muxName}.${paneTarget}`
: `${muxName}.%${paneTarget}`;
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try {
execSync(
`tmux pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
);
execSync(`tmux pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
return true;
} catch (err) {
console.error('[TmuxManager] Failed to start pipe-pane:', err);
@@ -1429,15 +1495,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return false;
}
const target = paneTarget.startsWith('%')
? `${muxName}.${paneTarget}`
: `${muxName}.%${paneTarget}`;
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try {
execSync(
`tmux pipe-pane -t ${shellescape(target)}`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
);
execSync(`tmux pipe-pane -t ${shellescape(target)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
return true;
} catch (err) {
console.error('[TmuxManager] Failed to stop pipe-pane:', err);
+4 -8
View File
@@ -86,12 +86,7 @@ const POLL_INTERVAL_MS = 1000;
const MAX_MESSAGE_LENGTH = 500;
/** Patterns that indicate plan mode / approval prompt */
const PLAN_MODE_PATTERNS = [
/ExitPlanMode/i,
/AskUserQuestion/i,
/Ready for user approval/i,
/approve.*plan/i,
];
const PLAN_MODE_PATTERNS = [/ExitPlanMode/i, /AskUserQuestion/i, /Ready for user approval/i, /approve.*plan/i];
// ========== TranscriptWatcher Class ==========
@@ -410,11 +405,12 @@ export class TranscriptWatcher extends EventEmitter {
if (entry.type !== 'assistant' || !entry.message?.content) return;
const content = entry.message.content;
const textToCheck = typeof content === 'string'
const textToCheck =
typeof content === 'string'
? content
: content
.filter((b): b is { type: 'text'; text: string } => b.type === 'text' && !!b.text)
.map(b => b.text)
.map((b) => b.text)
.join(' ');
// Also check for tool_use with ExitPlanMode or AskUserQuestion
+4 -1
View File
@@ -112,7 +112,10 @@ export class TunnelManager extends EventEmitter {
const binary = this.resolveCloudflared();
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;
}
+8 -10
View File
@@ -145,15 +145,7 @@ export interface SessionConfig {
/**
* Available session colors for visual differentiation
*/
export type SessionColor =
| 'default'
| 'red'
| 'orange'
| 'yellow'
| 'green'
| 'blue'
| 'purple'
| 'pink';
export type SessionColor = 'default' | 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' | 'pink';
/**
* Current state of a session
@@ -687,7 +679,13 @@ export const ErrorMessages: Record<ApiErrorCode, string> = {
/**
* 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 ==========
+27 -35
View File
@@ -115,11 +115,7 @@ export class CleanupManager implements Disposable {
* @param options - Optional configuration
* @returns Timer ID for manual clearing if needed
*/
setTimeout(
callback: () => void,
delay: number,
options?: TimerOptions
): string {
setTimeout(callback: () => void, delay: number, options?: TimerOptions): string {
const id = uuidv4();
const timeoutId = setTimeout(() => {
// Remove registration when timer fires naturally
@@ -148,11 +144,7 @@ export class CleanupManager implements Disposable {
* @param options - Optional configuration
* @returns Interval ID for manual clearing if needed
*/
setInterval(
callback: () => void,
delay: number,
options?: TimerOptions
): string {
setInterval(callback: () => void, delay: number, options?: TimerOptions): string {
const id = uuidv4();
const intervalId = setInterval(() => {
// Don't execute if stopped
@@ -179,11 +171,7 @@ export class CleanupManager implements Disposable {
* @param description - Human-readable description
* @returns Registration ID for manual removal if needed
*/
registerCleanup(
type: CleanupResourceType,
cleanup: () => void,
description: string
): string {
registerCleanup(type: CleanupResourceType, cleanup: () => void, description: string): string {
const id = uuidv4();
this.register({
id,
@@ -202,10 +190,7 @@ export class CleanupManager implements Disposable {
* @param description - Human-readable description
* @returns Registration ID
*/
registerWatcher(
watcher: { close: () => void },
description: string
): string {
registerWatcher(watcher: { close: () => void }, description: string): string {
return this.registerCleanup('watcher', () => watcher.close(), description);
}
@@ -218,19 +203,23 @@ export class CleanupManager implements Disposable {
* @param description - Human-readable description
* @returns Registration ID
*/
registerListener<T extends { removeListener?: (event: string, listener: () => void) => void; off?: (event: string, listener: () => void) => void }>(
emitter: T,
event: string,
listener: () => void,
description: string
): string {
return this.registerCleanup('listener', () => {
registerListener<
T extends {
removeListener?: (event: string, listener: () => void) => void;
off?: (event: string, listener: () => void) => void;
},
>(emitter: T, event: string, listener: () => void, description: string): string {
return this.registerCleanup(
'listener',
() => {
if (emitter.removeListener) {
emitter.removeListener(event, listener);
} else if (emitter.off) {
emitter.off(event, listener);
}
}, description);
},
description
);
}
/**
@@ -240,17 +229,18 @@ export class CleanupManager implements Disposable {
* @param description - Human-readable description
* @returns Registration ID
*/
registerStream(
stream: { destroy?: () => void; close?: () => void },
description: string
): string {
return this.registerCleanup('stream', () => {
registerStream(stream: { destroy?: () => void; close?: () => void }, description: string): string {
return this.registerCleanup(
'stream',
() => {
if (stream.destroy) {
stream.destroy();
} else if (stream.close) {
stream.close();
}
}, description);
},
description
);
}
/**
@@ -297,8 +287,10 @@ export class CleanupManager implements Disposable {
this.registrations.clear();
if (errors.length > 0) {
console.error(`[CleanupManager] ${errors.length} errors during disposal:`,
errors.map(e => e.description).join(', '));
console.error(
`[CleanupManager] ${errors.length} errors during disposal:`,
errors.map((e) => e.description).join(', ')
);
}
}
+2 -11
View File
@@ -19,17 +19,8 @@ export {
createAnsiPatternSimple,
stripAnsi,
} from './regex-patterns.js';
export {
MAX_SESSION_TOKENS,
validateTokenCounts,
validateTokensAndCost,
} from './token-validation.js';
export {
stringSimilarity,
normalizePhrase,
fuzzyPhraseMatch,
todoContentHash,
} from './string-similarity.js';
export { MAX_SESSION_TOKENS, validateTokenCounts, validateTokensAndCost } from './token-validation.js';
export { stringSimilarity, normalizePhrase, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
export { assertNever } from './type-safety.js';
export { wrapWithNice } from './nice-wrapper.js';
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
+4 -1
View File
@@ -41,7 +41,10 @@ export function resolveOpenCodeDir(): string | null {
// Try `which` first (respects current PATH)
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)) {
_openCodeDir = dirname(result);
return _openCodeDir;
+5 -2
View File
@@ -17,6 +17,7 @@
* Use this when you need complete ANSI stripping including OSC sequences.
* 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;
/**
@@ -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.
* 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;
/**
@@ -43,6 +45,7 @@ export const TOKEN_PATTERN = /(\d+(?:\.\d+)?)\s*([kKmM])?\s*tokens/;
* Use when you need a pattern without shared lastIndex state.
*/
export function createAnsiPatternFull(): RegExp {
// eslint-disable-next-line no-control-regex
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.
*/
export function createAnsiPatternSimple(): RegExp {
// eslint-disable-next-line no-control-regex
return /\x1b\[[0-9;]*[A-Za-z]/g;
}
@@ -74,5 +78,4 @@ export function stripAnsi(text: string): string {
*/
export const SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧]/;
export const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_\/\-. ~]+$/;
export const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_/\-. ~]+$/;
+6 -12
View File
@@ -245,29 +245,23 @@ export class StaleExpirationMap<K, V> implements Disposable {
/**
* Get all keys (non-expired).
*/
keys(): IterableIterator<K> {
const self = this;
return (function* () {
for (const [key, entry] of self.entries) {
if (!self.isExpired(entry)) {
*keys(): IterableIterator<K> {
for (const [key, entry] of this.entries) {
if (!this.isExpired(entry)) {
yield key;
}
}
})();
}
/**
* Get all values (non-expired).
*/
values(): IterableIterator<V> {
const self = this;
return (function* () {
for (const [, entry] of self.entries) {
if (!self.isExpired(entry)) {
*values(): IterableIterator<V> {
for (const [, entry] of this.entries) {
if (!this.isExpired(entry)) {
yield entry.value;
}
}
})();
}
/**
+3 -7
View File
@@ -139,7 +139,7 @@ export function isSimilarByDistance(a: string, b: string, maxDistance = 2): bool
export function normalizePhrase(phrase: string): string {
return phrase
.toUpperCase()
.replace(/[\s_\-\.]+/g, '') // Remove whitespace, underscores, hyphens, dots
.replace(/[\s_\-.]+/g, '') // Remove whitespace, underscores, hyphens, dots
.trim();
}
@@ -161,11 +161,7 @@ export function normalizePhrase(phrase: string): string {
* fuzzyPhraseMatch('TASK_DONE', 'TASKDONE') // true (separator)
* fuzzyPhraseMatch('COMPLETE', 'FINISHED') // false (different word)
*/
export function fuzzyPhraseMatch(
phrase1: string,
phrase2: string,
maxDistance = 2
): boolean {
export function fuzzyPhraseMatch(phrase1: string, phrase2: string, maxDistance = 2): boolean {
const norm1 = normalizePhrase(phrase1);
const norm2 = normalizePhrase(phrase2);
@@ -201,7 +197,7 @@ export function todoContentHash(content: string): string {
let hash = 0;
for (let i = 0; i < normalized.length; i++) {
const char = normalized.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
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
* @returns Object with isValid flag and optional error reason
*/
export function validateTokenCounts(
inputTokens: number,
outputTokens: number
): { isValid: boolean; reason?: string } {
export function validateTokenCounts(inputTokens: number, outputTokens: number): { isValid: boolean; reason?: string } {
if (inputTokens < 0 || outputTokens < 0) {
return {
isValid: false,
+112 -40
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 */
export function isValidWorkingDir(p: string): boolean {
if (!p || !p.startsWith('/')) return false;
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')) {
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')
) {
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) */
const BLOCKED_ENV_KEYS = new Set([
'PATH', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'NODE_OPTIONS',
'CODEMAN_MUX_NAME', 'CODEMAN_TMUX',
'PATH',
'LD_PRELOAD',
'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 */
function isAllowedEnvKey(key: string): boolean {
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 */
const safeEnvOverridesSchema = z.record(z.string(), z.string()).optional().refine(
const safeEnvOverridesSchema = z
.record(z.string(), z.string())
.optional()
.refine(
(val) => {
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.' },
);
{
message:
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_* and OPENCODE_* keys are allowed.',
}
);
// ========== 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.
*/
/** Schema for OpenCode-specific configuration */
const OpenCodeConfigSchema = z.object({
model: z.string().max(100).regex(/^[a-zA-Z0-9._\-/]+$/).optional(),
const OpenCodeConfigSchema = z
.object({
model: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9._\-/]+$/)
.optional(),
autoAllowTools: z.boolean().optional(),
continueSession: z.string().max(100).regex(/^[a-zA-Z0-9_-]+$/).optional(),
continueSession: z
.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();
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({
workingDir: safePathSchema.optional(),
@@ -108,7 +151,9 @@ export const ResizeSchema = z.object({
* Creates a new case folder.
*/
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(),
});
@@ -119,7 +164,10 @@ export const CreateCaseSchema = z.object({
* Creates case (if needed) and starts interactive session.
*/
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(),
openCodeConfig: OpenCodeConfigSchema,
});
@@ -175,25 +223,30 @@ export const RespawnConfigSchema = z.object({
* Schema for PUT /api/config
* Updates application configuration with whitelist of allowed fields.
*/
export const ConfigUpdateSchema = z.object({
export const ConfigUpdateSchema = z
.object({
pollIntervalMs: z.number().int().min(100).max(60000).optional(),
defaultTimeoutMs: z.number().int().min(1000).max(3600000).optional(),
maxConcurrentSessions: z.number().int().min(1).max(50).optional(),
respawn: RespawnConfigSchema.optional(),
}).strict();
})
.strict();
/**
* Schema for PUT /api/settings
* Explicit allowlist of known settings fields — prevents arbitrary key persistence.
*/
const NotificationEventSchema = z.object({
const NotificationEventSchema = z
.object({
enabled: z.boolean().optional(),
browser: z.boolean().optional(),
audio: z.boolean().optional(),
push: z.boolean().optional(),
}).optional();
})
.optional();
export const SettingsUpdateSchema = z.object({
export const SettingsUpdateSchema = z
.object({
// Paths
defaultClaudeMdPath: z.string().max(500).optional(),
defaultWorkingDir: z.string().max(500).optional(),
@@ -220,12 +273,15 @@ export const SettingsUpdateSchema = z.object({
claudeMode: z.string().max(50).optional(),
allowedTools: z.string().max(2000).optional(),
// CPU priority
nice: z.object({
nice: z
.object({
enabled: z.boolean().optional(),
niceValue: z.number().int().min(-20).max(19).optional(),
}).optional(),
})
.optional(),
// Notification preferences (cross-device sync)
notificationPreferences: z.object({
notificationPreferences: z
.object({
enabled: z.boolean().optional(),
browserNotifications: z.boolean().optional(),
audioAlerts: z.boolean().optional(),
@@ -233,7 +289,8 @@ export const SettingsUpdateSchema = z.object({
muteCritical: z.boolean().optional(),
muteWarning: z.boolean().optional(),
muteInfo: z.boolean().optional(),
eventTypes: z.object({
eventTypes: z
.object({
permission_prompt: NotificationEventSchema,
elicitation_dialog: NotificationEventSchema,
idle_prompt: NotificationEventSchema,
@@ -244,17 +301,22 @@ export const SettingsUpdateSchema = z.object({
ralph_complete: NotificationEventSchema,
subagent_spawn: NotificationEventSchema,
subagent_complete: NotificationEventSchema,
}).optional(),
})
.optional(),
_version: z.number().optional(),
}).optional(),
})
.optional(),
// Voice settings (cross-device sync)
voiceSettings: z.object({
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();
})
.optional(),
})
.strict();
/**
* 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());
/** PUT /api/subagent-window-states */
export const SubagentWindowStatesSchema = z.object({
export const SubagentWindowStatesSchema = z
.object({
minimized: z.record(z.string(), z.boolean()).optional(),
open: z.array(z.string()).optional(),
}).passthrough();
})
.passthrough();
/** PUT /api/subagent-parents */
export const SubagentParentMapSchema = z.record(z.string(), z.string());
@@ -423,14 +487,22 @@ export const PushPreferencesUpdateSchema = z.object({
/** POST /api/ralph-loop/start */
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),
completionPhrase: z.string().max(100).default('COMPLETE'),
maxIterations: z.number().int().min(0).max(1000).nullable().default(10),
enableRespawn: z.boolean().default(false),
planItems: z.array(z.object({
planItems: z
.array(
z.object({
content: z.string(),
priority: z.string().optional(),
enabled: z.boolean().default(true),
})).optional(),
})
)
.optional(),
});
+545 -199
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