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>
@@ -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
|
||||||
@@ -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
|
||||||
@@ -41,6 +41,14 @@ Thumbs.db
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
|
||||||
|
# Generated output
|
||||||
|
out/
|
||||||
|
screenshots-echo-diag/
|
||||||
|
tools/remotion/out/
|
||||||
|
|
||||||
|
# Claude Code plan tracking
|
||||||
|
plan.json
|
||||||
|
|
||||||
# Unfinished TUI (local development only)
|
# Unfinished TUI (local development only)
|
||||||
src/tui/
|
src/tui/
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
@@ -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/
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"printWidth": 120,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
@@ -64,7 +64,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
**CRITICAL**: `npm run dev` shows CLI help, NOT the web server.
|
**Note**: `npm run dev` starts the web server (equivalent to `npx tsx src/index.ts web`).
|
||||||
|
|
||||||
**Default port**: `3000` (web UI at `http://localhost:3000`)
|
**Default port**: `3000` (web UI at `http://localhost:3000`)
|
||||||
|
|
||||||
@@ -92,7 +92,6 @@ journalctl --user -u codeman-web -f
|
|||||||
|
|
||||||
## Common Gotchas
|
## Common Gotchas
|
||||||
|
|
||||||
- **`npm run dev` is NOT the web server** — it shows CLI help. Use `npx tsx src/index.ts web`
|
|
||||||
- **Single-line prompts only** — `writeViaMux()` sends text and Enter separately; multi-line breaks Ink
|
- **Single-line prompts only** — `writeViaMux()` sends text and Enter separately; multi-line breaks Ink
|
||||||
- **Don't kill tmux sessions blindly** — Check `$CODEMAN_TMUX` first; you might be inside one
|
- **Don't kill tmux sessions blindly** — Check `$CODEMAN_TMUX` first; you might be inside one
|
||||||
- **Never run full test suite** — `npx vitest run` spawns/kills tmux sessions and will crash your Codeman session. Run individual test files only.
|
- **Never run full test suite** — `npx vitest run` spawns/kills tmux sessions and will crash your Codeman session. Run individual test files only.
|
||||||
|
|||||||
@@ -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/**',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -10,20 +10,27 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "node scripts/postinstall.js",
|
"postinstall": "node scripts/postinstall.js",
|
||||||
"build": "tsc && chmod +x dist/index.js && mkdir -p dist/web dist/templates dist/web/public/vendor && cp -r src/web/public dist/web/ && cp src/templates/case-template.md dist/templates/ && cp node_modules/xterm/css/xterm.css dist/web/public/vendor/ && npx esbuild node_modules/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js && npx esbuild node_modules/xterm-addon-fit/lib/xterm-addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js && cp node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js && npx esbuild node_modules/xterm-addon-unicode11/lib/xterm-addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js && npx esbuild dist/web/public/app.js --minify --drop:console --outfile=dist/web/public/app.js --allow-overwrite && npx esbuild dist/web/public/styles.css --minify --outfile=dist/web/public/styles.css --allow-overwrite && npx esbuild dist/web/public/mobile.css --minify --outfile=dist/web/public/mobile.css --allow-overwrite && for f in dist/web/public/*.js dist/web/public/*.css dist/web/public/*.html dist/web/public/vendor/*.js dist/web/public/vendor/*.css; do [ -f \"$f\" ] && gzip -9 -k -f \"$f\" && { brotli -9 -k -f \"$f\" 2>/dev/null || true; }; done",
|
"build": "node scripts/build.mjs",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsx src/index.ts",
|
"dev": "tsx src/index.ts web",
|
||||||
"web": "node dist/index.js web",
|
"web": "node dist/index.js web",
|
||||||
"clean": "rm -rf dist",
|
"clean": "rm -rf dist",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "eslint 'src/**/*.ts'",
|
||||||
|
"lint:fix": "eslint 'src/**/*.ts' --fix",
|
||||||
|
"format": "prettier --write 'src/**/*.ts'",
|
||||||
|
"format:check": "prettier --check 'src/**/*.ts'",
|
||||||
"capture:subagents": "node scripts/capture-subagent-screenshots.mjs",
|
"capture:subagents": "node scripts/capture-subagent-screenshots.mjs",
|
||||||
"changeset": "changeset",
|
"changeset": "changeset",
|
||||||
"version-packages": "changeset version",
|
"version-packages": "changeset version",
|
||||||
"release": "changeset publish"
|
"release": "changeset publish"
|
||||||
},
|
},
|
||||||
|
"workspaces": [
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude",
|
"claude",
|
||||||
"claude-code",
|
"claude-code",
|
||||||
@@ -43,15 +50,12 @@
|
|||||||
"@fastify/compress": "^8.3.1",
|
"@fastify/compress": "^8.3.1",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/static": "^8.0.0",
|
"@fastify/static": "^8.0.0",
|
||||||
"@remotion/cli": "4.0.429",
|
|
||||||
"@remotion/transitions": "4.0.429",
|
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"chokidar": "^3.6.0",
|
"chokidar": "^3.6.0",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"fastify": "^5.1.0",
|
"fastify": "^5.1.0",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"remotion": "4.0.429",
|
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^10.0.0",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"xterm": "^5.3.0",
|
"xterm": "^5.3.0",
|
||||||
@@ -62,6 +66,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@changesets/cli": "^2.29.8",
|
"@changesets/cli": "^2.29.8",
|
||||||
|
"@eslint/js": "^9.0.0",
|
||||||
|
"@remotion/cli": "4.0.429",
|
||||||
|
"@remotion/transitions": "4.0.429",
|
||||||
"@types/node": "^20.19.33",
|
"@types/node": "^20.19.33",
|
||||||
"@types/pngjs": "^6.0.5",
|
"@types/pngjs": "^6.0.5",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
@@ -70,12 +77,16 @@
|
|||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"agent-browser": "^0.6.0",
|
"agent-browser": "^0.6.0",
|
||||||
"esbuild": "^0.27.3",
|
"esbuild": "^0.27.3",
|
||||||
|
"eslint": "^9.0.0",
|
||||||
"pixelmatch": "^6.0.0",
|
"pixelmatch": "^6.0.0",
|
||||||
"playwright": "^1.58.0",
|
"playwright": "^1.58.0",
|
||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
|
"prettier": "^3.4.0",
|
||||||
"puppeteer": "^24.36.0",
|
"puppeteer": "^24.36.0",
|
||||||
|
"remotion": "4.0.429",
|
||||||
"tsx": "^4.15.0",
|
"tsx": "^4.15.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.0.0",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -115,7 +115,7 @@ export abstract class AiCheckerBase<
|
|||||||
V extends string,
|
V extends string,
|
||||||
C extends AiCheckerConfigBase,
|
C extends AiCheckerConfigBase,
|
||||||
R extends AiCheckerResultBase<V>,
|
R extends AiCheckerResultBase<V>,
|
||||||
S extends AiCheckerStateBase<V>
|
S extends AiCheckerStateBase<V>,
|
||||||
> extends EventEmitter {
|
> extends EventEmitter {
|
||||||
protected config: C;
|
protected config: C;
|
||||||
protected sessionId: string;
|
protected sessionId: string;
|
||||||
@@ -205,9 +205,7 @@ export abstract class AiCheckerBase<
|
|||||||
super();
|
super();
|
||||||
this.sessionId = sessionId;
|
this.sessionId = sessionId;
|
||||||
// Filter out undefined values to prevent overwriting defaults
|
// Filter out undefined values to prevent overwriting defaults
|
||||||
const filteredConfig = Object.fromEntries(
|
const filteredConfig = Object.fromEntries(Object.entries(config).filter(([, v]) => v !== undefined)) as Partial<C>;
|
||||||
Object.entries(config).filter(([, v]) => v !== undefined)
|
|
||||||
) as Partial<C>;
|
|
||||||
this.config = { ...defaultConfig, ...filteredConfig };
|
this.config = { ...defaultConfig, ...filteredConfig };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,9 +340,7 @@ export abstract class AiCheckerBase<
|
|||||||
/** Update configuration at runtime */
|
/** Update configuration at runtime */
|
||||||
updateConfig(config: Partial<C>): void {
|
updateConfig(config: Partial<C>): void {
|
||||||
// Filter out undefined values to prevent overwriting existing config
|
// Filter out undefined values to prevent overwriting existing config
|
||||||
const filteredConfig = Object.fromEntries(
|
const filteredConfig = Object.fromEntries(Object.entries(config).filter(([, v]) => v !== undefined)) as Partial<C>;
|
||||||
Object.entries(config).filter(([, v]) => v !== undefined)
|
|
||||||
) as Partial<C>;
|
|
||||||
this.config = { ...this.config, ...filteredConfig };
|
this.config = { ...this.config, ...filteredConfig };
|
||||||
if (config.enabled === false) {
|
if (config.enabled === false) {
|
||||||
this.disable('Disabled by config');
|
this.disable('Disabled by config');
|
||||||
@@ -369,9 +365,8 @@ export abstract class AiCheckerBase<
|
|||||||
|
|
||||||
// Prepare the terminal buffer (strip ANSI, trim to maxContextChars)
|
// Prepare the terminal buffer (strip ANSI, trim to maxContextChars)
|
||||||
const stripped = terminalBuffer.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '');
|
const stripped = terminalBuffer.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '');
|
||||||
const trimmed = stripped.length > this.config.maxContextChars
|
const trimmed =
|
||||||
? stripped.slice(-this.config.maxContextChars)
|
stripped.length > this.config.maxContextChars ? stripped.slice(-this.config.maxContextChars) : stripped;
|
||||||
: stripped;
|
|
||||||
|
|
||||||
// Build the prompt
|
// Build the prompt
|
||||||
const prompt = this.buildPrompt(trimmed);
|
const prompt = this.buildPrompt(trimmed);
|
||||||
@@ -411,16 +406,15 @@ export abstract class AiCheckerBase<
|
|||||||
// No existing session, that's fine
|
// No existing session, that's fine
|
||||||
}
|
}
|
||||||
|
|
||||||
const muxProcess = childSpawn('tmux', [
|
const muxProcess = childSpawn('tmux', ['new-session', '-d', '-s', this.checkMuxName, 'bash', '-c', fullCmd], {
|
||||||
'new-session', '-d', '-s', this.checkMuxName,
|
|
||||||
'bash', '-c', fullCmd
|
|
||||||
], {
|
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
});
|
});
|
||||||
muxProcess.unref();
|
muxProcess.unref();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Failed to spawn ${this.checkDescription} tmux session: ${err instanceof Error ? err.message : String(err)}`);
|
throw new Error(
|
||||||
|
`Failed to spawn ${this.checkDescription} tmux session: ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll the temp file for completion
|
// Poll the temp file for completion
|
||||||
@@ -530,7 +524,9 @@ export abstract class AiCheckerBase<
|
|||||||
|
|
||||||
private handleError(errorMsg: string): void {
|
private handleError(errorMsg: string): void {
|
||||||
this.consecutiveErrors++;
|
this.consecutiveErrors++;
|
||||||
this.log(`${this.checkDescription} error (${this.consecutiveErrors}/${this.config.maxConsecutiveErrors}): ${errorMsg}`);
|
this.log(
|
||||||
|
`${this.checkDescription} error (${this.consecutiveErrors}/${this.config.maxConsecutiveErrors}): ${errorMsg}`
|
||||||
|
);
|
||||||
|
|
||||||
if (this.consecutiveErrors >= this.config.maxConsecutiveErrors) {
|
if (this.consecutiveErrors >= this.config.maxConsecutiveErrors) {
|
||||||
this.disable(`${this.config.maxConsecutiveErrors} consecutive errors: ${errorMsg}`);
|
this.disable(`${this.config.maxConsecutiveErrors} consecutive errors: ${errorMsg}`);
|
||||||
|
|||||||
@@ -33,13 +33,13 @@ import {
|
|||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
|
|
||||||
export interface AiIdleCheckConfig extends AiCheckerConfigBase {}
|
export type AiIdleCheckConfig = AiCheckerConfigBase;
|
||||||
|
|
||||||
export type AiCheckVerdict = 'IDLE' | 'WORKING' | 'ERROR';
|
export type AiCheckVerdict = 'IDLE' | 'WORKING' | 'ERROR';
|
||||||
|
|
||||||
export interface AiCheckResult extends AiCheckerResultBase<AiCheckVerdict> {}
|
export type AiCheckResult = AiCheckerResultBase<AiCheckVerdict>;
|
||||||
|
|
||||||
export interface AiCheckState extends AiCheckerStateBase<AiCheckVerdict> {}
|
export type AiCheckState = AiCheckerStateBase<AiCheckVerdict>;
|
||||||
|
|
||||||
// ========== Constants ==========
|
// ========== Constants ==========
|
||||||
|
|
||||||
@@ -123,12 +123,7 @@ Remember: When uncertain, answer WORKING.`;
|
|||||||
* Manages AI-powered idle detection by spawning a fresh Claude CLI session
|
* Manages AI-powered idle detection by spawning a fresh Claude CLI session
|
||||||
* to analyze terminal output and provide a definitive IDLE/WORKING verdict.
|
* to analyze terminal output and provide a definitive IDLE/WORKING verdict.
|
||||||
*/
|
*/
|
||||||
export class AiIdleChecker extends AiCheckerBase<
|
export class AiIdleChecker extends AiCheckerBase<AiCheckVerdict, AiIdleCheckConfig, AiCheckResult, AiCheckState> {
|
||||||
AiCheckVerdict,
|
|
||||||
AiIdleCheckConfig,
|
|
||||||
AiCheckResult,
|
|
||||||
AiCheckState
|
|
||||||
> {
|
|
||||||
protected readonly muxNamePrefix = 'codeman-aicheck-';
|
protected readonly muxNamePrefix = 'codeman-aicheck-';
|
||||||
protected readonly doneMarker = '__AICHECK_DONE__';
|
protected readonly doneMarker = '__AICHECK_DONE__';
|
||||||
protected readonly tempFilePrefix = 'codeman-aicheck';
|
protected readonly tempFilePrefix = 'codeman-aicheck';
|
||||||
|
|||||||
@@ -32,13 +32,13 @@ import {
|
|||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
|
|
||||||
export interface AiPlanCheckConfig extends AiCheckerConfigBase {}
|
export type AiPlanCheckConfig = AiCheckerConfigBase;
|
||||||
|
|
||||||
export type AiPlanCheckVerdict = 'PLAN_MODE' | 'NOT_PLAN_MODE' | 'ERROR';
|
export type AiPlanCheckVerdict = 'PLAN_MODE' | 'NOT_PLAN_MODE' | 'ERROR';
|
||||||
|
|
||||||
export interface AiPlanCheckResult extends AiCheckerResultBase<AiPlanCheckVerdict> {}
|
export type AiPlanCheckResult = AiCheckerResultBase<AiPlanCheckVerdict>;
|
||||||
|
|
||||||
export interface AiPlanCheckState extends AiCheckerStateBase<AiPlanCheckVerdict> {}
|
export type AiPlanCheckState = AiCheckerStateBase<AiPlanCheckVerdict>;
|
||||||
|
|
||||||
// ========== Constants ==========
|
// ========== Constants ==========
|
||||||
|
|
||||||
|
|||||||
@@ -73,25 +73,25 @@ const FOLLOW_MODE_PATTERN = /\s-[A-Za-z]*f[A-Za-z]*\s|\s--follow\s/;
|
|||||||
* Note: This is a simpler approach - we run it on each command string
|
* Note: This is a simpler approach - we run it on each command string
|
||||||
* rather than trying to match globally.
|
* rather than trying to match globally.
|
||||||
*/
|
*/
|
||||||
const FILE_PATH_PATTERN = /(?:^|\s|['"]|=)([\/~][^\s'"<>|;&\n]+)/g;
|
const FILE_PATH_PATTERN = /(?:^|\s|['"]|=)([/~][^\s'"<>|;&\n]+)/g;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pattern to detect paths that are likely not real files (flags, etc.)
|
* Pattern to detect paths that are likely not real files (flags, etc.)
|
||||||
*/
|
*/
|
||||||
const INVALID_PATH_PATTERN = /^[\/~]-|\/dev\/null$/;
|
const INVALID_PATH_PATTERN = /^[/~]-|\/dev\/null$/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pattern to detect command suggestions in plain text output.
|
* Pattern to detect command suggestions in plain text output.
|
||||||
* Matches lines like "tail -f /path/to/file" without the ● Bash() wrapper.
|
* Matches lines like "tail -f /path/to/file" without the ● Bash() wrapper.
|
||||||
* This catches commands Claude mentions but doesn't execute.
|
* This catches commands Claude mentions but doesn't execute.
|
||||||
*/
|
*/
|
||||||
const TEXT_COMMAND_PATTERN = /^\s*(tail|cat|head|less|grep|watch|multitail)\s+(?:-[^\s]+\s+)*([\/~][^\s'"<>|;&\n]+)/;
|
const TEXT_COMMAND_PATTERN = /^\s*(tail|cat|head|less|grep|watch|multitail)\s+(?:-[^\s]+\s+)*([/~][^\s'"<>|;&\n]+)/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pattern to detect log file paths mentioned in text (even without commands).
|
* Pattern to detect log file paths mentioned in text (even without commands).
|
||||||
* Matches paths ending in .log, .txt, .out, or in common log directories.
|
* Matches paths ending in .log, .txt, .out, or in common log directories.
|
||||||
*/
|
*/
|
||||||
const LOG_FILE_MENTION_PATTERN = /([\/~][^\s'"<>|;&\n]*(?:\.log|\.txt|\.out|\/log\/[^\s'"<>|;&\n]+))/g;
|
const LOG_FILE_MENTION_PATTERN = /([/~][^\s'"<>|;&\n]*(?:\.log|\.txt|\.out|\/log\/[^\s'"<>|;&\n]+))/g;
|
||||||
|
|
||||||
// ========== Event Interfaces ==========
|
// ========== Event Interfaces ==========
|
||||||
|
|
||||||
@@ -245,7 +245,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
|||||||
*/
|
*/
|
||||||
private isShallowRootPath(path: string): boolean {
|
private isShallowRootPath(path: string): boolean {
|
||||||
if (!path.startsWith('/')) return false;
|
if (!path.startsWith('/')) return false;
|
||||||
const parts = path.split('/').filter(p => p !== '');
|
const parts = path.split('/').filter((p) => p !== '');
|
||||||
return parts.length === 1;
|
return parts.length === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,10 +354,10 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
|||||||
isFilePathTracked(filePath: string): boolean {
|
isFilePathTracked(filePath: string): boolean {
|
||||||
const normalizedNew = this.normalizePath(filePath);
|
const normalizedNew = this.normalizePath(filePath);
|
||||||
|
|
||||||
return Array.from(this._activeTools.values()).some(t => {
|
return Array.from(this._activeTools.values()).some((t) => {
|
||||||
if (t.status !== 'running') return false;
|
if (t.status !== 'running') return false;
|
||||||
|
|
||||||
return t.filePaths.some(existingPath => {
|
return t.filePaths.some((existingPath) => {
|
||||||
const normalizedExisting = this.normalizePath(existingPath);
|
const normalizedExisting = this.normalizePath(existingPath);
|
||||||
return normalizedExisting === normalizedNew;
|
return normalizedExisting === normalizedNew;
|
||||||
});
|
});
|
||||||
@@ -418,9 +418,8 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
|||||||
// Prevent unbounded growth
|
// Prevent unbounded growth
|
||||||
if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) {
|
if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) {
|
||||||
const trimPoint = this._lineBuffer.lastIndexOf('\n', MAX_LINE_BUFFER_SIZE / 2);
|
const trimPoint = this._lineBuffer.lastIndexOf('\n', MAX_LINE_BUFFER_SIZE / 2);
|
||||||
this._lineBuffer = trimPoint > 0
|
this._lineBuffer =
|
||||||
? this._lineBuffer.slice(trimPoint + 1)
|
trimPoint > 0 ? this._lineBuffer.slice(trimPoint + 1) : this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
|
||||||
: this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process complete lines
|
// Process complete lines
|
||||||
@@ -445,9 +444,8 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
|||||||
|
|
||||||
if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) {
|
if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) {
|
||||||
const trimPoint = this._lineBuffer.lastIndexOf('\n', MAX_LINE_BUFFER_SIZE / 2);
|
const trimPoint = this._lineBuffer.lastIndexOf('\n', MAX_LINE_BUFFER_SIZE / 2);
|
||||||
this._lineBuffer = trimPoint > 0
|
this._lineBuffer =
|
||||||
? this._lineBuffer.slice(trimPoint + 1)
|
trimPoint > 0 ? this._lineBuffer.slice(trimPoint + 1) : this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
|
||||||
: this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = this._lineBuffer.split('\n');
|
const lines = this._lineBuffer.split('\n');
|
||||||
@@ -472,7 +470,6 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
|||||||
* Process a single pre-stripped line of terminal output.
|
* Process a single pre-stripped line of terminal output.
|
||||||
*/
|
*/
|
||||||
private processCleanLine(cleanLine: string): void {
|
private processCleanLine(cleanLine: string): void {
|
||||||
|
|
||||||
// Check for tool start
|
// Check for tool start
|
||||||
const startMatch = cleanLine.match(BASH_TOOL_START_PATTERN);
|
const startMatch = cleanLine.match(BASH_TOOL_START_PATTERN);
|
||||||
if (startMatch) {
|
if (startMatch) {
|
||||||
@@ -484,7 +481,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
|||||||
const filePaths = this.extractFilePaths(command);
|
const filePaths = this.extractFilePaths(command);
|
||||||
|
|
||||||
// Skip if any file path is already tracked (cross-pattern dedup)
|
// Skip if any file path is already tracked (cross-pattern dedup)
|
||||||
if (filePaths.some(fp => this.isFilePathTracked(fp))) {
|
if (filePaths.some((fp) => this.isFilePathTracked(fp))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,8 +499,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
|||||||
// Enforce max tools limit
|
// Enforce max tools limit
|
||||||
if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
|
if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
|
||||||
// Remove oldest tool
|
// Remove oldest tool
|
||||||
const oldest = Array.from(this._activeTools.entries())
|
const oldest = Array.from(this._activeTools.entries()).sort((a, b) => a[1].startedAt - b[1].startedAt)[0];
|
||||||
.sort((a, b) => a[1].startedAt - b[1].startedAt)[0];
|
|
||||||
if (oldest) {
|
if (oldest) {
|
||||||
this._activeTools.delete(oldest[0]);
|
this._activeTools.delete(oldest[0]);
|
||||||
}
|
}
|
||||||
@@ -671,6 +667,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
|||||||
*/
|
*/
|
||||||
private stripAnsi(str: string): string {
|
private stripAnsi(str: string): string {
|
||||||
// Comprehensive ANSI pattern
|
// Comprehensive ANSI pattern
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
return str.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g, '');
|
return str.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,17 +21,11 @@ const pkg = require('../package.json') as { version: string };
|
|||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
program
|
program.name('codeman').description('Claude Code session manager with autonomous Ralph Loop').version(pkg.version);
|
||||||
.name('codeman')
|
|
||||||
.description('Claude Code session manager with autonomous Ralph Loop')
|
|
||||||
.version(pkg.version);
|
|
||||||
|
|
||||||
// ============ Session Commands ============
|
// ============ Session Commands ============
|
||||||
|
|
||||||
const sessionCmd = program
|
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
|
||||||
.command('session')
|
|
||||||
.alias('s')
|
|
||||||
.description('Manage Claude sessions');
|
|
||||||
|
|
||||||
sessionCmd
|
sessionCmd
|
||||||
.command('start')
|
.command('start')
|
||||||
@@ -83,11 +77,12 @@ sessionCmd
|
|||||||
console.log(' (none)');
|
console.log(' (none)');
|
||||||
} else {
|
} else {
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
const status = session.status === 'idle'
|
const status =
|
||||||
? chalk.green('idle')
|
session.status === 'idle'
|
||||||
: session.status === 'busy'
|
? chalk.green('idle')
|
||||||
? chalk.yellow('busy')
|
: session.status === 'busy'
|
||||||
: chalk.red(session.status);
|
? chalk.yellow('busy')
|
||||||
|
: chalk.red(session.status);
|
||||||
console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status} ${session.workingDir}`);
|
console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status} ${session.workingDir}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,11 +101,12 @@ sessionCmd
|
|||||||
if (sessions.length === 0 && activeSessions.length > 0) {
|
if (sessions.length === 0 && activeSessions.length > 0) {
|
||||||
console.log(chalk.bold('\nActive Sessions (from web server):'));
|
console.log(chalk.bold('\nActive Sessions (from web server):'));
|
||||||
for (const session of activeSessions) {
|
for (const session of activeSessions) {
|
||||||
const status = session.status === 'idle'
|
const status =
|
||||||
? chalk.green('idle')
|
session.status === 'idle'
|
||||||
: session.status === 'busy'
|
? chalk.green('idle')
|
||||||
? chalk.yellow('busy')
|
: session.status === 'busy'
|
||||||
: chalk.red(session.status);
|
? chalk.yellow('busy')
|
||||||
|
: chalk.red(session.status);
|
||||||
const name = session.name ? ` (${session.name})` : '';
|
const name = session.name ? ` (${session.name})` : '';
|
||||||
const mode = session.mode === 'shell' ? chalk.gray(' [shell]') : '';
|
const mode = session.mode === 'shell' ? chalk.gray(' [shell]') : '';
|
||||||
const cost = session.totalCost ? chalk.gray(` $${session.totalCost.toFixed(4)}`) : '';
|
const cost = session.totalCost ? chalk.gray(` $${session.totalCost.toFixed(4)}`) : '';
|
||||||
@@ -126,9 +122,7 @@ sessionCmd
|
|||||||
.option('-e, --errors', 'Show stderr instead of stdout')
|
.option('-e, --errors', 'Show stderr instead of stdout')
|
||||||
.action((id, options) => {
|
.action((id, options) => {
|
||||||
const manager = getSessionManager();
|
const manager = getSessionManager();
|
||||||
const output = options.errors
|
const output = options.errors ? manager.getSessionError(id) : manager.getSessionOutput(id);
|
||||||
? manager.getSessionError(id)
|
|
||||||
: manager.getSessionOutput(id);
|
|
||||||
|
|
||||||
if (output === null) {
|
if (output === null) {
|
||||||
console.log(chalk.yellow(`Session ${id} not found or not active`));
|
console.log(chalk.yellow(`Session ${id} not found or not active`));
|
||||||
@@ -145,10 +139,7 @@ sessionCmd
|
|||||||
|
|
||||||
// ============ Task Commands ============
|
// ============ Task Commands ============
|
||||||
|
|
||||||
const taskCmd = program
|
const taskCmd = program.command('task').alias('t').description('Manage tasks');
|
||||||
.command('task')
|
|
||||||
.alias('t')
|
|
||||||
.description('Manage tasks');
|
|
||||||
|
|
||||||
taskCmd
|
taskCmd
|
||||||
.command('add <prompt>')
|
.command('add <prompt>')
|
||||||
@@ -205,7 +196,9 @@ taskCmd
|
|||||||
|
|
||||||
const counts = queue.getCount();
|
const counts = queue.getCount();
|
||||||
console.log(chalk.bold('\nSummary:'));
|
console.log(chalk.bold('\nSummary:'));
|
||||||
console.log(` Pending: ${counts.pending}, Running: ${counts.running}, Completed: ${counts.completed}, Failed: ${counts.failed}`);
|
console.log(
|
||||||
|
` Pending: ${counts.pending}, Running: ${counts.running}, Completed: ${counts.completed}, Failed: ${counts.failed}`
|
||||||
|
);
|
||||||
console.log('');
|
console.log('');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -276,10 +269,7 @@ taskCmd
|
|||||||
|
|
||||||
// ============ Ralph Loop Commands ============
|
// ============ Ralph Loop Commands ============
|
||||||
|
|
||||||
const ralphCmd = program
|
const ralphCmd = program.command('ralph').alias('r').description('Control the Ralph autonomous loop');
|
||||||
.command('ralph')
|
|
||||||
.alias('r')
|
|
||||||
.description('Control the Ralph autonomous loop');
|
|
||||||
|
|
||||||
ralphCmd
|
ralphCmd
|
||||||
.command('start')
|
.command('start')
|
||||||
@@ -355,17 +345,16 @@ ralphCmd
|
|||||||
});
|
});
|
||||||
|
|
||||||
function printStats(stats: ReturnType<ReturnType<typeof getRalphLoop>['getStats']>) {
|
function printStats(stats: ReturnType<ReturnType<typeof getRalphLoop>['getStats']>) {
|
||||||
const statusColor =
|
const statusColor = stats.status === 'running' ? chalk.green : stats.status === 'paused' ? chalk.yellow : chalk.gray;
|
||||||
stats.status === 'running' ? chalk.green :
|
|
||||||
stats.status === 'paused' ? chalk.yellow :
|
|
||||||
chalk.gray;
|
|
||||||
|
|
||||||
console.log(chalk.bold('\nRalph Loop Status:'));
|
console.log(chalk.bold('\nRalph Loop Status:'));
|
||||||
console.log(` Status: ${statusColor(stats.status)}`);
|
console.log(` Status: ${statusColor(stats.status)}`);
|
||||||
console.log(` Elapsed: ${stats.elapsedHours.toFixed(2)} hours`);
|
console.log(` Elapsed: ${stats.elapsedHours.toFixed(2)} hours`);
|
||||||
if (stats.minDurationMs) {
|
if (stats.minDurationMs) {
|
||||||
const minHours = stats.minDurationMs / (1000 * 60 * 60);
|
const minHours = stats.minDurationMs / (1000 * 60 * 60);
|
||||||
console.log(` Min Duration: ${minHours.toFixed(2)} hours (${stats.minDurationReached ? 'reached' : 'not reached'})`);
|
console.log(
|
||||||
|
` Min Duration: ${minHours.toFixed(2)} hours (${stats.minDurationReached ? 'reached' : 'not reached'})`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(chalk.bold('\nTasks:'));
|
console.log(chalk.bold('\nTasks:'));
|
||||||
@@ -422,10 +411,7 @@ program
|
|||||||
console.log(` Completed: ${taskCounts.completed}`);
|
console.log(` Completed: ${taskCounts.completed}`);
|
||||||
console.log(` Failed: ${taskCounts.failed}`);
|
console.log(` Failed: ${taskCounts.failed}`);
|
||||||
|
|
||||||
const statusColor =
|
const statusColor = loopStatus === 'running' ? chalk.green : loopStatus === 'paused' ? chalk.yellow : chalk.gray;
|
||||||
loopStatus === 'running' ? chalk.green :
|
|
||||||
loopStatus === 'paused' ? chalk.yellow :
|
|
||||||
chalk.gray;
|
|
||||||
console.log(chalk.bold('\nRalph Loop:'));
|
console.log(chalk.bold('\nRalph Loop:'));
|
||||||
console.log(` Status: ${statusColor(loopStatus)}`);
|
console.log(` Status: ${statusColor(loopStatus)}`);
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -481,11 +467,12 @@ program
|
|||||||
console.log(' (none)');
|
console.log(' (none)');
|
||||||
} else {
|
} else {
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
const status = session.status === 'idle'
|
const status =
|
||||||
? chalk.green('idle')
|
session.status === 'idle'
|
||||||
: session.status === 'busy'
|
? chalk.green('idle')
|
||||||
? chalk.yellow('busy')
|
: session.status === 'busy'
|
||||||
: chalk.red(session.status);
|
? chalk.yellow('busy')
|
||||||
|
: chalk.red(session.status);
|
||||||
console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status} ${session.workingDir}`);
|
console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status} ${session.workingDir}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,7 +171,10 @@ export class FileStreamManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : 'UNKNOWN';
|
const errorCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : 'UNKNOWN';
|
||||||
console.warn(`[FileStreamManager] Failed to stat file "${absolutePath}" (${errorCode}):`, err instanceof Error ? err.message : String(err));
|
console.warn(
|
||||||
|
`[FileStreamManager] Failed to stat file "${absolutePath}" (${errorCode}):`,
|
||||||
|
err instanceof Error ? err.message : String(err)
|
||||||
|
);
|
||||||
return { success: false, error: 'File not found or not accessible' };
|
return { success: false, error: 'File not found or not accessible' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,9 +382,7 @@ export class FileStreamManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve to absolute path
|
// Resolve to absolute path
|
||||||
let absolutePath = isAbsolute(expandedPath)
|
let absolutePath = isAbsolute(expandedPath) ? resolve(expandedPath) : resolve(workingDir, expandedPath);
|
||||||
? resolve(expandedPath)
|
|
||||||
: resolve(workingDir, expandedPath);
|
|
||||||
|
|
||||||
// Resolve symlinks to prevent symlink attacks — validate the real target,
|
// Resolve symlinks to prevent symlink attacks — validate the real target,
|
||||||
// not the symlink itself. Fall back to resolved path if file doesn't exist yet.
|
// not the symlink itself. Fall back to resolved path if file doesn't exist yet.
|
||||||
|
|||||||
@@ -24,15 +24,7 @@ export interface ImageWatcherEvents {
|
|||||||
// ========== Constants ==========
|
// ========== Constants ==========
|
||||||
|
|
||||||
/** Supported image file extensions (lowercase) */
|
/** Supported image file extensions (lowercase) */
|
||||||
const IMAGE_EXTENSIONS = new Set([
|
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
|
||||||
'.png',
|
|
||||||
'.jpg',
|
|
||||||
'.jpeg',
|
|
||||||
'.gif',
|
|
||||||
'.webp',
|
|
||||||
'.bmp',
|
|
||||||
'.svg',
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** Time to wait for file writes to stabilize (ms) */
|
/** Time to wait for file writes to stabilize (ms) */
|
||||||
const STABILITY_THRESHOLD_MS = 500;
|
const STABILITY_THRESHOLD_MS = 500;
|
||||||
@@ -171,7 +163,12 @@ export class ImageWatcher extends EventEmitter {
|
|||||||
// Ignore common heavy directories for performance
|
// Ignore common heavy directories for performance
|
||||||
ignored: (path: string) => {
|
ignored: (path: string) => {
|
||||||
// Skip node_modules, .git, and other heavy directories
|
// Skip node_modules, .git, and other heavy directories
|
||||||
if (path.includes('/node_modules/') || path.includes('/.git/') || path.includes('/dist/') || path.includes('/.next/')) {
|
if (
|
||||||
|
path.includes('/node_modules/') ||
|
||||||
|
path.includes('/.git/') ||
|
||||||
|
path.includes('/dist/') ||
|
||||||
|
path.includes('/.next/')
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const ext = extname(path).toLowerCase();
|
const ext = extname(path).toLowerCase();
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ let errorResetTimer: ReturnType<typeof setTimeout> | null = null;
|
|||||||
function trackError(): void {
|
function trackError(): void {
|
||||||
consecutiveErrors++;
|
consecutiveErrors++;
|
||||||
if (errorResetTimer) clearTimeout(errorResetTimer);
|
if (errorResetTimer) clearTimeout(errorResetTimer);
|
||||||
errorResetTimer = setTimeout(() => { consecutiveErrors = 0; }, ERROR_RESET_MS);
|
errorResetTimer = setTimeout(() => {
|
||||||
|
consecutiveErrors = 0;
|
||||||
|
}, ERROR_RESET_MS);
|
||||||
|
|
||||||
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
||||||
console.error(`[FATAL] ${MAX_CONSECUTIVE_ERRORS} consecutive unhandled errors — exiting for systemd restart`);
|
console.error(`[FATAL] ${MAX_CONSECUTIVE_ERRORS} consecutive unhandled errors — exiting for systemd restart`);
|
||||||
|
|||||||
@@ -7,7 +7,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { EventEmitter } from 'node:events';
|
import type { EventEmitter } from 'node:events';
|
||||||
import type { ProcessStats, PersistedRespawnConfig, NiceConfig, ClaudeMode, SessionMode, OpenCodeConfig } from './types.js';
|
import type {
|
||||||
|
ProcessStats,
|
||||||
|
PersistedRespawnConfig,
|
||||||
|
NiceConfig,
|
||||||
|
ClaudeMode,
|
||||||
|
SessionMode,
|
||||||
|
OpenCodeConfig,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Multiplexer session metadata.
|
* Multiplexer session metadata.
|
||||||
|
|||||||
@@ -19,10 +19,7 @@ import { Session } from './session.js';
|
|||||||
import type { TerminalMultiplexer } from './mux-interface.js';
|
import type { TerminalMultiplexer } from './mux-interface.js';
|
||||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import {
|
import { RESEARCH_AGENT_PROMPT, PLANNER_PROMPT } from './prompts/index.js';
|
||||||
RESEARCH_AGENT_PROMPT,
|
|
||||||
PLANNER_PROMPT,
|
|
||||||
} from './prompts/index.js';
|
|
||||||
import { PlanTaskStatus, TddPhase } from './types.js';
|
import { PlanTaskStatus, TddPhase } from './types.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -170,7 +167,7 @@ export class PlanOrchestrator {
|
|||||||
mux: TerminalMultiplexer,
|
mux: TerminalMultiplexer,
|
||||||
workingDir: string = process.cwd(),
|
workingDir: string = process.cwd(),
|
||||||
outputDir?: string,
|
outputDir?: string,
|
||||||
modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record<string, string> },
|
modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record<string, string> }
|
||||||
) {
|
) {
|
||||||
this.mux = mux;
|
this.mux = mux;
|
||||||
this.workingDir = workingDir;
|
this.workingDir = workingDir;
|
||||||
@@ -193,7 +190,11 @@ export class PlanOrchestrator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const promptPath = join(agentDir, 'prompt.md');
|
const promptPath = join(agentDir, 'prompt.md');
|
||||||
writeFileSync(promptPath, `# ${agentType} Agent Prompt\n\nGenerated: ${new Date().toISOString()}\nDuration: ${(durationMs / 1000).toFixed(1)}s\n\n## Task\n${this.taskDescription}\n\n## Prompt\n${prompt}\n`, 'utf-8');
|
writeFileSync(
|
||||||
|
promptPath,
|
||||||
|
`# ${agentType} Agent Prompt\n\nGenerated: ${new Date().toISOString()}\nDuration: ${(durationMs / 1000).toFixed(1)}s\n\n## Task\n${this.taskDescription}\n\n## Prompt\n${prompt}\n`,
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
const resultPath = join(agentDir, 'result.json');
|
const resultPath = join(agentDir, 'result.json');
|
||||||
writeFileSync(resultPath, JSON.stringify(result, null, 2), 'utf-8');
|
writeFileSync(resultPath, JSON.stringify(result, null, 2), 'utf-8');
|
||||||
@@ -227,9 +228,9 @@ export class PlanOrchestrator {
|
|||||||
|
|
||||||
private generateSummary(result: DetailedPlanResult): string {
|
private generateSummary(result: DetailedPlanResult): string {
|
||||||
const items = result.items || [];
|
const items = result.items || [];
|
||||||
const p0 = items.filter(i => i.priority === 'P0');
|
const p0 = items.filter((i) => i.priority === 'P0');
|
||||||
const p1 = items.filter(i => i.priority === 'P1');
|
const p1 = items.filter((i) => i.priority === 'P1');
|
||||||
const p2 = items.filter(i => i.priority === 'P2');
|
const p2 = items.filter((i) => i.priority === 'P2');
|
||||||
|
|
||||||
let md = `# Plan Summary\n\n`;
|
let md = `# Plan Summary\n\n`;
|
||||||
md += `Generated: ${new Date().toISOString()}\n`;
|
md += `Generated: ${new Date().toISOString()}\n`;
|
||||||
@@ -392,10 +393,29 @@ export class PlanOrchestrator {
|
|||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
if (this.cancelled) {
|
if (this.cancelled) {
|
||||||
return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error: 'Cancelled', durationMs: 0 };
|
return {
|
||||||
|
success: false,
|
||||||
|
findings: {
|
||||||
|
externalResources: [],
|
||||||
|
codebasePatterns: [],
|
||||||
|
technicalRecommendations: [],
|
||||||
|
potentialChallenges: [],
|
||||||
|
recommendedTools: [],
|
||||||
|
},
|
||||||
|
enrichedTaskDescription: taskDescription,
|
||||||
|
error: 'Cancelled',
|
||||||
|
durationMs: 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubagent?.({ type: 'started', agentId, agentType: 'research', model: this.researchModel, status: 'running', detail: 'Researching...' });
|
onSubagent?.({
|
||||||
|
type: 'started',
|
||||||
|
agentId,
|
||||||
|
agentType: 'research',
|
||||||
|
model: this.researchModel,
|
||||||
|
status: 'running',
|
||||||
|
detail: 'Researching...',
|
||||||
|
});
|
||||||
|
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
workingDir: this.workingDir,
|
workingDir: this.workingDir,
|
||||||
@@ -411,7 +431,14 @@ export class PlanOrchestrator {
|
|||||||
// Start progress interval before try block to ensure cleanup in finally
|
// Start progress interval before try block to ensure cleanup in finally
|
||||||
const progressInterval = setInterval(() => {
|
const progressInterval = setInterval(() => {
|
||||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||||
onSubagent?.({ type: 'progress', agentId, agentType: 'research', model: this.researchModel, status: 'running', detail: `${elapsed}s elapsed` });
|
onSubagent?.({
|
||||||
|
type: 'progress',
|
||||||
|
agentId,
|
||||||
|
agentType: 'research',
|
||||||
|
model: this.researchModel,
|
||||||
|
status: 'running',
|
||||||
|
detail: `${elapsed}s elapsed`,
|
||||||
|
});
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -424,14 +451,54 @@ export class PlanOrchestrator {
|
|||||||
// Extract JSON from response
|
// Extract JSON from response
|
||||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||||
if (!jsonMatch) {
|
if (!jsonMatch) {
|
||||||
onSubagent?.({ type: 'failed', agentId, agentType: 'research', model: this.researchModel, status: 'failed', error: 'No JSON found', durationMs });
|
onSubagent?.({
|
||||||
return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error: 'No JSON in response', durationMs };
|
type: 'failed',
|
||||||
|
agentId,
|
||||||
|
agentType: 'research',
|
||||||
|
model: this.researchModel,
|
||||||
|
status: 'failed',
|
||||||
|
error: 'No JSON found',
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
findings: {
|
||||||
|
externalResources: [],
|
||||||
|
codebasePatterns: [],
|
||||||
|
technicalRecommendations: [],
|
||||||
|
potentialChallenges: [],
|
||||||
|
recommendedTools: [],
|
||||||
|
},
|
||||||
|
enrichedTaskDescription: taskDescription,
|
||||||
|
error: 'No JSON in response',
|
||||||
|
durationMs,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = tryParseJSON(jsonMatch[0]);
|
const parsed = tryParseJSON(jsonMatch[0]);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
onSubagent?.({ type: 'failed', agentId, agentType: 'research', model: this.researchModel, status: 'failed', error: parsed.error, durationMs });
|
onSubagent?.({
|
||||||
return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error: parsed.error, durationMs };
|
type: 'failed',
|
||||||
|
agentId,
|
||||||
|
agentType: 'research',
|
||||||
|
model: this.researchModel,
|
||||||
|
status: 'failed',
|
||||||
|
error: parsed.error,
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
findings: {
|
||||||
|
externalResources: [],
|
||||||
|
codebasePatterns: [],
|
||||||
|
technicalRecommendations: [],
|
||||||
|
potentialChallenges: [],
|
||||||
|
recommendedTools: [],
|
||||||
|
},
|
||||||
|
enrichedTaskDescription: taskDescription,
|
||||||
|
error: parsed.error,
|
||||||
|
durationMs,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = parsed.data as Record<string, unknown>;
|
const data = parsed.data as Record<string, unknown>;
|
||||||
@@ -444,20 +511,48 @@ export class PlanOrchestrator {
|
|||||||
potentialChallenges: Array.isArray(data.potentialChallenges) ? data.potentialChallenges : [],
|
potentialChallenges: Array.isArray(data.potentialChallenges) ? data.potentialChallenges : [],
|
||||||
recommendedTools: Array.isArray(data.recommendedTools) ? data.recommendedTools : [],
|
recommendedTools: Array.isArray(data.recommendedTools) ? data.recommendedTools : [],
|
||||||
},
|
},
|
||||||
enrichedTaskDescription: typeof data.enrichedTaskDescription === 'string' ? data.enrichedTaskDescription : taskDescription,
|
enrichedTaskDescription:
|
||||||
|
typeof data.enrichedTaskDescription === 'string' ? data.enrichedTaskDescription : taskDescription,
|
||||||
durationMs,
|
durationMs,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.saveAgentOutput('research', prompt, result, durationMs);
|
this.saveAgentOutput('research', prompt, result, durationMs);
|
||||||
onSubagent?.({ type: 'completed', agentId, agentType: 'research', model: this.researchModel, status: 'completed', durationMs });
|
onSubagent?.({
|
||||||
|
type: 'completed',
|
||||||
|
agentId,
|
||||||
|
agentType: 'research',
|
||||||
|
model: this.researchModel,
|
||||||
|
status: 'completed',
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.runningSessions.delete(session);
|
this.runningSessions.delete(session);
|
||||||
const durationMs = Date.now() - startTime;
|
const durationMs = Date.now() - startTime;
|
||||||
const error = err instanceof Error ? err.message : String(err);
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
onSubagent?.({ type: 'failed', agentId, agentType: 'research', model: this.researchModel, status: 'failed', error, durationMs });
|
onSubagent?.({
|
||||||
return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error, durationMs };
|
type: 'failed',
|
||||||
|
agentId,
|
||||||
|
agentType: 'research',
|
||||||
|
model: this.researchModel,
|
||||||
|
status: 'failed',
|
||||||
|
error,
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
findings: {
|
||||||
|
externalResources: [],
|
||||||
|
codebasePatterns: [],
|
||||||
|
technicalRecommendations: [],
|
||||||
|
potentialChallenges: [],
|
||||||
|
recommendedTools: [],
|
||||||
|
},
|
||||||
|
enrichedTaskDescription: taskDescription,
|
||||||
|
error,
|
||||||
|
durationMs,
|
||||||
|
};
|
||||||
} finally {
|
} finally {
|
||||||
// Always clear the progress interval to prevent memory leaks
|
// Always clear the progress interval to prevent memory leaks
|
||||||
clearInterval(progressInterval);
|
clearInterval(progressInterval);
|
||||||
@@ -469,7 +564,13 @@ export class PlanOrchestrator {
|
|||||||
researchContext: string,
|
researchContext: string,
|
||||||
onProgress?: ProgressCallback,
|
onProgress?: ProgressCallback,
|
||||||
onSubagent?: SubagentCallback
|
onSubagent?: SubagentCallback
|
||||||
): Promise<{ success: boolean; items?: PlanItem[]; gaps?: string[]; warnings?: string[]; error?: string }> {
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
items?: PlanItem[];
|
||||||
|
gaps?: string[];
|
||||||
|
warnings?: string[];
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
const agentId = `planner-${Date.now()}`;
|
const agentId = `planner-${Date.now()}`;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
@@ -477,7 +578,14 @@ export class PlanOrchestrator {
|
|||||||
return { success: false, error: 'Cancelled' };
|
return { success: false, error: 'Cancelled' };
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubagent?.({ type: 'started', agentId, agentType: 'planner', model: this.plannerModel, status: 'running', detail: 'Generating plan...' });
|
onSubagent?.({
|
||||||
|
type: 'started',
|
||||||
|
agentId,
|
||||||
|
agentType: 'planner',
|
||||||
|
model: this.plannerModel,
|
||||||
|
status: 'running',
|
||||||
|
detail: 'Generating plan...',
|
||||||
|
});
|
||||||
|
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
workingDir: this.workingDir,
|
workingDir: this.workingDir,
|
||||||
@@ -488,14 +596,22 @@ export class PlanOrchestrator {
|
|||||||
|
|
||||||
this.runningSessions.add(session);
|
this.runningSessions.add(session);
|
||||||
|
|
||||||
const prompt = PLANNER_PROMPT
|
const prompt = PLANNER_PROMPT.replace('{TASK}', taskDescription).replace(
|
||||||
.replace('{TASK}', taskDescription)
|
'{RESEARCH_CONTEXT}',
|
||||||
.replace('{RESEARCH_CONTEXT}', researchContext || '');
|
researchContext || ''
|
||||||
|
);
|
||||||
|
|
||||||
// Start progress interval before try block to ensure cleanup in finally
|
// Start progress interval before try block to ensure cleanup in finally
|
||||||
const progressInterval = setInterval(() => {
|
const progressInterval = setInterval(() => {
|
||||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||||
onSubagent?.({ type: 'progress', agentId, agentType: 'planner', model: this.plannerModel, status: 'running', detail: `${elapsed}s elapsed` });
|
onSubagent?.({
|
||||||
|
type: 'progress',
|
||||||
|
agentId,
|
||||||
|
agentType: 'planner',
|
||||||
|
model: this.plannerModel,
|
||||||
|
status: 'running',
|
||||||
|
detail: `${elapsed}s elapsed`,
|
||||||
|
});
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -508,13 +624,29 @@ export class PlanOrchestrator {
|
|||||||
// Extract JSON from response
|
// Extract JSON from response
|
||||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||||
if (!jsonMatch) {
|
if (!jsonMatch) {
|
||||||
onSubagent?.({ type: 'failed', agentId, agentType: 'planner', model: this.plannerModel, status: 'failed', error: 'No JSON found', durationMs });
|
onSubagent?.({
|
||||||
|
type: 'failed',
|
||||||
|
agentId,
|
||||||
|
agentType: 'planner',
|
||||||
|
model: this.plannerModel,
|
||||||
|
status: 'failed',
|
||||||
|
error: 'No JSON found',
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
return { success: false, error: 'No JSON in response' };
|
return { success: false, error: 'No JSON in response' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = tryParseJSON(jsonMatch[0]);
|
const parsed = tryParseJSON(jsonMatch[0]);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
onSubagent?.({ type: 'failed', agentId, agentType: 'planner', model: this.plannerModel, status: 'failed', error: parsed.error, durationMs });
|
onSubagent?.({
|
||||||
|
type: 'failed',
|
||||||
|
agentId,
|
||||||
|
agentType: 'planner',
|
||||||
|
model: this.plannerModel,
|
||||||
|
status: 'failed',
|
||||||
|
error: parsed.error,
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
return { success: false, error: parsed.error };
|
return { success: false, error: parsed.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,7 +656,15 @@ export class PlanOrchestrator {
|
|||||||
const warnings: string[] = Array.isArray(data.warnings) ? data.warnings : [];
|
const warnings: string[] = Array.isArray(data.warnings) ? data.warnings : [];
|
||||||
|
|
||||||
this.saveAgentOutput('planner', prompt, { items, gaps, warnings }, durationMs);
|
this.saveAgentOutput('planner', prompt, { items, gaps, warnings }, durationMs);
|
||||||
onSubagent?.({ type: 'completed', agentId, agentType: 'planner', model: this.plannerModel, status: 'completed', itemCount: items.length, durationMs });
|
onSubagent?.({
|
||||||
|
type: 'completed',
|
||||||
|
agentId,
|
||||||
|
agentType: 'planner',
|
||||||
|
model: this.plannerModel,
|
||||||
|
status: 'completed',
|
||||||
|
itemCount: items.length,
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
|
||||||
onProgress?.('planning', `Generated ${items.length} tasks`);
|
onProgress?.('planning', `Generated ${items.length} tasks`);
|
||||||
|
|
||||||
@@ -533,7 +673,15 @@ export class PlanOrchestrator {
|
|||||||
this.runningSessions.delete(session);
|
this.runningSessions.delete(session);
|
||||||
const durationMs = Date.now() - startTime;
|
const durationMs = Date.now() - startTime;
|
||||||
const error = err instanceof Error ? err.message : String(err);
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
onSubagent?.({ type: 'failed', agentId, agentType: 'planner', model: this.plannerModel, status: 'failed', error, durationMs });
|
onSubagent?.({
|
||||||
|
type: 'failed',
|
||||||
|
agentId,
|
||||||
|
agentType: 'planner',
|
||||||
|
model: this.plannerModel,
|
||||||
|
status: 'failed',
|
||||||
|
error,
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
return { success: false, error };
|
return { success: false, error };
|
||||||
} finally {
|
} finally {
|
||||||
// Always clear the progress interval to prevent memory leaks
|
// Always clear the progress interval to prevent memory leaks
|
||||||
|
|||||||
@@ -282,10 +282,7 @@ export class RalphLoop extends EventEmitter {
|
|||||||
this.store.setRalphLoopState({ lastCheckAt: Date.now() });
|
this.store.setRalphLoopState({ lastCheckAt: Date.now() });
|
||||||
|
|
||||||
// Run independent checks in parallel for better performance
|
// Run independent checks in parallel for better performance
|
||||||
await Promise.all([
|
await Promise.all([this.checkTimeouts(), this.assignTasks()]);
|
||||||
this.checkTimeouts(),
|
|
||||||
this.assignTasks(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Check if we should auto-generate tasks (depends on assignment results)
|
// Check if we should auto-generate tasks (depends on assignment results)
|
||||||
if (this.autoGenerateTasks && this.shouldGenerateTasks()) {
|
if (this.autoGenerateTasks && this.shouldGenerateTasks()) {
|
||||||
@@ -409,11 +406,7 @@ export class RalphLoop extends EventEmitter {
|
|||||||
// 2. Min duration not reached
|
// 2. Min duration not reached
|
||||||
// 3. We have idle sessions
|
// 3. We have idle sessions
|
||||||
const counts = this.taskQueue.getCount();
|
const counts = this.taskQueue.getCount();
|
||||||
return (
|
return counts.pending === 0 && !this.isMinDurationReached() && this.sessionManager.getIdleSessions().length > 0;
|
||||||
counts.pending === 0 &&
|
|
||||||
!this.isMinDurationReached() &&
|
|
||||||
this.sessionManager.getIdleSessions().length > 0
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generateFollowUpTasks(): Promise<void> {
|
private async generateFollowUpTasks(): Promise<void> {
|
||||||
|
|||||||
@@ -34,12 +34,7 @@ import {
|
|||||||
PlanTaskStatus,
|
PlanTaskStatus,
|
||||||
TddPhase,
|
TddPhase,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import {
|
import { ANSI_ESCAPE_PATTERN_SIMPLE, fuzzyPhraseMatch, todoContentHash, stringSimilarity } from './utils/index.js';
|
||||||
ANSI_ESCAPE_PATTERN_SIMPLE,
|
|
||||||
fuzzyPhraseMatch,
|
|
||||||
todoContentHash,
|
|
||||||
stringSimilarity,
|
|
||||||
} from './utils/index.js';
|
|
||||||
import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js';
|
import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js';
|
||||||
import { MAX_TODOS_PER_SESSION } from './config/map-limits.js';
|
import { MAX_TODOS_PER_SESSION } from './config/map-limits.js';
|
||||||
|
|
||||||
@@ -151,8 +146,19 @@ const MAX_PLAN_HISTORY = 10;
|
|||||||
* P1-002: Configurable false positive prevention
|
* P1-002: Configurable false positive prevention
|
||||||
*/
|
*/
|
||||||
const COMMON_COMPLETION_PHRASES = new Set([
|
const COMMON_COMPLETION_PHRASES = new Set([
|
||||||
'DONE', 'COMPLETE', 'FINISHED', 'OK', 'YES', 'TRUE', 'SUCCESS',
|
'DONE',
|
||||||
'READY', 'COMPLETED', 'PASSED', 'END', 'STOP', 'EXIT',
|
'COMPLETE',
|
||||||
|
'FINISHED',
|
||||||
|
'OK',
|
||||||
|
'YES',
|
||||||
|
'TRUE',
|
||||||
|
'SUCCESS',
|
||||||
|
'READY',
|
||||||
|
'COMPLETED',
|
||||||
|
'PASSED',
|
||||||
|
'END',
|
||||||
|
'STOP',
|
||||||
|
'EXIT',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -249,9 +255,9 @@ const TODO_PLAIN_CHECKMARK_PATTERN = /✔\s+(.+)/g;
|
|||||||
* Prevents false positives from tool invocations and Claude commentary
|
* Prevents false positives from tool invocations and Claude commentary
|
||||||
*/
|
*/
|
||||||
const TODO_EXCLUDE_PATTERNS = [
|
const TODO_EXCLUDE_PATTERNS = [
|
||||||
/^(?:Bash|Search|Read|Write|Glob|Grep|Edit|Task)\s*\(/i, // Tool invocations
|
/^(?:Bash|Search|Read|Write|Glob|Grep|Edit|Task)\s*\(/i, // Tool invocations
|
||||||
/^(?:I'll |Let me |Now I|First,|Task \d+:|Result:|Error:)/i, // Claude commentary
|
/^(?:I'll |Let me |Now I|First,|Task \d+:|Result:|Error:)/i, // Claude commentary
|
||||||
/^\S+\([^)]+\)$/, // Generic function call pattern
|
/^\S+\([^)]+\)$/, // Generic function call pattern
|
||||||
];
|
];
|
||||||
|
|
||||||
// ---------- Loop Status Patterns ----------
|
// ---------- Loop Status Patterns ----------
|
||||||
@@ -313,7 +319,8 @@ const TODOWRITE_PATTERN = /TodoWrite|todo(?:s)?\s*(?:updated|written|saved)|Todo
|
|||||||
* Examples: "All 8 files have been created", "All tasks completed", "Everything is done"
|
* Examples: "All 8 files have been created", "All tasks completed", "Everything is done"
|
||||||
* Used to mark all tracked todos as complete at once
|
* Used to mark all tracked todos as complete at once
|
||||||
*/
|
*/
|
||||||
const ALL_COMPLETE_PATTERN = /all\s+(?:\d+\s+)?(?:tasks?|files?|items?)\s+(?:have\s+been\s+|are\s+)?(?:completed?|done|finished|created)|completed?\s+all\s+(?:\d+\s+)?tasks?|all\s+done|everything\s+(?:is\s+)?(?:completed?|done)|finished\s+all\s+tasks?/i;
|
const ALL_COMPLETE_PATTERN =
|
||||||
|
/all\s+(?:\d+\s+)?(?:tasks?|files?|items?)\s+(?:have\s+been\s+|are\s+)?(?:completed?|done|finished|created)|completed?\s+all\s+(?:\d+\s+)?tasks?|all\s+done|everything\s+(?:is\s+)?(?:completed?|done)|finished\s+all\s+tasks?/i;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts count from "all N items" messages
|
* Extracts count from "all N items" messages
|
||||||
@@ -327,7 +334,8 @@ const ALL_COUNT_PATTERN = /all\s+(\d+)\s+(?:tasks?|files?|items?)/i;
|
|||||||
* Examples: "Task #5 is done", "marked as completed", "todo 3 finished"
|
* Examples: "Task #5 is done", "marked as completed", "todo 3 finished"
|
||||||
* Used to update specific todo items by number
|
* Used to update specific todo items by number
|
||||||
*/
|
*/
|
||||||
const TASK_DONE_PATTERN = /(?:task|item|todo)\s*(?:#?\d+|"\s*[^"]+\s*")?\s*(?:is\s+)?(?:done|completed?|finished)|(?:completed?|done|finished)\s+(?:task|item)\s*(?:#?\d+)?|marking\s+(?:.*?\s+)?(?:as\s+)?completed?|marked\s+(?:.*?\s+)?(?:as\s+)?completed?/i;
|
const TASK_DONE_PATTERN =
|
||||||
|
/(?:task|item|todo)\s*(?:#?\d+|"\s*[^"]+\s*")?\s*(?:is\s+)?(?:done|completed?|finished)|(?:completed?|done|finished)\s+(?:task|item)\s*(?:#?\d+)?|marking\s+(?:.*?\s+)?(?:as\s+)?completed?|marked\s+(?:.*?\s+)?(?:as\s+)?completed?/i;
|
||||||
|
|
||||||
// ---------- Utility Patterns ----------
|
// ---------- Utility Patterns ----------
|
||||||
|
|
||||||
@@ -411,48 +419,48 @@ const COMPLETION_INDICATOR_PATTERNS = [
|
|||||||
|
|
||||||
/** P0 (Critical) priority patterns - highest severity issues */
|
/** P0 (Critical) priority patterns - highest severity issues */
|
||||||
const P0_PRIORITY_PATTERNS = [
|
const P0_PRIORITY_PATTERNS = [
|
||||||
/\bP0\b|\(P0\)|:?\s*P0\s*:/, // Explicit P0
|
/\bP0\b|\(P0\)|:?\s*P0\s*:/, // Explicit P0
|
||||||
/\bCRITICAL\b/, // Critical keyword
|
/\bCRITICAL\b/, // Critical keyword
|
||||||
/\bBLOCKER\b/, // Blocker
|
/\bBLOCKER\b/, // Blocker
|
||||||
/\bURGENT\b/, // Urgent
|
/\bURGENT\b/, // Urgent
|
||||||
/\bSECURITY\b/, // Security issues
|
/\bSECURITY\b/, // Security issues
|
||||||
/\bCRASH(?:ES|ING)?\b/, // Crash, crashes, crashing
|
/\bCRASH(?:ES|ING)?\b/, // Crash, crashes, crashing
|
||||||
/\bBROKEN\b/, // Broken
|
/\bBROKEN\b/, // Broken
|
||||||
/\bDATA\s*LOSS\b/, // Data loss
|
/\bDATA\s*LOSS\b/, // Data loss
|
||||||
/\bPRODUCTION\s*(?:DOWN|ISSUE|BUG)\b/, // Production issues
|
/\bPRODUCTION\s*(?:DOWN|ISSUE|BUG)\b/, // Production issues
|
||||||
/\bHOTFIX\b/, // Hotfix
|
/\bHOTFIX\b/, // Hotfix
|
||||||
/\bSEVERITY\s*1\b/, // Severity 1
|
/\bSEVERITY\s*1\b/, // Severity 1
|
||||||
];
|
];
|
||||||
|
|
||||||
/** P1 (High) priority patterns - important issues requiring attention */
|
/** P1 (High) priority patterns - important issues requiring attention */
|
||||||
const P1_PRIORITY_PATTERNS = [
|
const P1_PRIORITY_PATTERNS = [
|
||||||
/\bP1\b|\(P1\)|:?\s*P1\s*:/, // Explicit P1
|
/\bP1\b|\(P1\)|:?\s*P1\s*:/, // Explicit P1
|
||||||
/\bHIGH\s*PRIORITY\b/, // High priority
|
/\bHIGH\s*PRIORITY\b/, // High priority
|
||||||
/\bIMPORTANT\b/, // Important
|
/\bIMPORTANT\b/, // Important
|
||||||
/\bBUG\b/, // Bug
|
/\bBUG\b/, // Bug
|
||||||
/\bFIX\b/, // Fix (as task type)
|
/\bFIX\b/, // Fix (as task type)
|
||||||
/\bERROR\b/, // Error
|
/\bERROR\b/, // Error
|
||||||
/\bFAIL(?:S|ED|ING|URE)?\b/, // Fail variants
|
/\bFAIL(?:S|ED|ING|URE)?\b/, // Fail variants
|
||||||
/\bREGRESSION\b/, // Regression
|
/\bREGRESSION\b/, // Regression
|
||||||
/\bMUST\s*(?:HAVE|FIX|DO)\b/, // Must have/fix/do
|
/\bMUST\s*(?:HAVE|FIX|DO)\b/, // Must have/fix/do
|
||||||
/\bSEVERITY\s*2\b/, // Severity 2
|
/\bSEVERITY\s*2\b/, // Severity 2
|
||||||
/\bREQUIRED\b/, // Required
|
/\bREQUIRED\b/, // Required
|
||||||
];
|
];
|
||||||
|
|
||||||
/** P2 (Medium) priority patterns - lower priority improvements */
|
/** P2 (Medium) priority patterns - lower priority improvements */
|
||||||
const P2_PRIORITY_PATTERNS = [
|
const P2_PRIORITY_PATTERNS = [
|
||||||
/\bP2\b|\(P2\)|:?\s*P2\s*:/, // Explicit P2
|
/\bP2\b|\(P2\)|:?\s*P2\s*:/, // Explicit P2
|
||||||
/\bNICE\s*TO\s*HAVE\b/, // Nice to have
|
/\bNICE\s*TO\s*HAVE\b/, // Nice to have
|
||||||
/\bLOW\s*PRIORITY\b/, // Low priority
|
/\bLOW\s*PRIORITY\b/, // Low priority
|
||||||
/\bREFACTOR\b/, // Refactor
|
/\bREFACTOR\b/, // Refactor
|
||||||
/\bCLEANUP\b/, // Cleanup
|
/\bCLEANUP\b/, // Cleanup
|
||||||
/\bIMPROVE(?:MENT)?\b/, // Improve/Improvement
|
/\bIMPROVE(?:MENT)?\b/, // Improve/Improvement
|
||||||
/\bOPTIMIZ(?:E|ATION)\b/, // Optimize/Optimization
|
/\bOPTIMIZ(?:E|ATION)\b/, // Optimize/Optimization
|
||||||
/\bCONSIDER\b/, // Consider
|
/\bCONSIDER\b/, // Consider
|
||||||
/\bWOULD\s*BE\s*NICE\b/, // Would be nice
|
/\bWOULD\s*BE\s*NICE\b/, // Would be nice
|
||||||
/\bENHANCE(?:MENT)?\b/, // Enhance/Enhancement
|
/\bENHANCE(?:MENT)?\b/, // Enhance/Enhancement
|
||||||
/\bTECH(?:NICAL)?\s*DEBT\b/, // Tech debt
|
/\bTECH(?:NICAL)?\s*DEBT\b/, // Tech debt
|
||||||
/\bDOCUMENT(?:ATION)?\b/, // Documentation
|
/\bDOCUMENT(?:ATION)?\b/, // Documentation
|
||||||
];
|
];
|
||||||
|
|
||||||
// ========== Event Types ==========
|
// ========== Event Types ==========
|
||||||
@@ -950,7 +958,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
|
|
||||||
const wasEnabled = this._loopState.enabled;
|
const wasEnabled = this._loopState.enabled;
|
||||||
this._loopState = createInitialRalphTrackerState();
|
this._loopState = createInitialRalphTrackerState();
|
||||||
this._loopState.enabled = wasEnabled; // Keep enabled status
|
this._loopState.enabled = wasEnabled; // Keep enabled status
|
||||||
this._todos.clear();
|
this._todos.clear();
|
||||||
this._completionPhraseCount.clear();
|
this._completionPhraseCount.clear();
|
||||||
this._taskNumberToContent.clear();
|
this._taskNumberToContent.clear();
|
||||||
@@ -1241,7 +1249,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
|
|
||||||
// Check if all todos are complete (adds 20 points)
|
// Check if all todos are complete (adds 20 points)
|
||||||
const todoArray = Array.from(this._todos.values());
|
const todoArray = Array.from(this._todos.values());
|
||||||
if (todoArray.length > 0 && todoArray.every(t => t.status === 'completed')) {
|
if (todoArray.length > 0 && todoArray.every((t) => t.status === 'completed')) {
|
||||||
signals.allTodosComplete = true;
|
signals.allTodosComplete = true;
|
||||||
score += 20;
|
score += 20;
|
||||||
}
|
}
|
||||||
@@ -1262,10 +1270,12 @@ export class RalphTracker extends EventEmitter {
|
|||||||
if (context) {
|
if (context) {
|
||||||
const lowerContext = context.toLowerCase();
|
const lowerContext = context.toLowerCase();
|
||||||
// Deduct points if phrase appears in prompt-like context
|
// Deduct points if phrase appears in prompt-like context
|
||||||
if (lowerContext.includes('output:') ||
|
if (
|
||||||
lowerContext.includes('completion phrase') ||
|
lowerContext.includes('output:') ||
|
||||||
lowerContext.includes('output exactly') ||
|
lowerContext.includes('completion phrase') ||
|
||||||
lowerContext.includes('when done')) {
|
lowerContext.includes('output exactly') ||
|
||||||
|
lowerContext.includes('when done')
|
||||||
|
) {
|
||||||
signals.contextAppropriate = false;
|
signals.contextAppropriate = false;
|
||||||
score -= 20;
|
score -= 20;
|
||||||
} else {
|
} else {
|
||||||
@@ -1335,7 +1345,6 @@ export class RalphTracker extends EventEmitter {
|
|||||||
* Use this when the caller has already stripped ANSI to avoid redundant regex work.
|
* Use this when the caller has already stripped ANSI to avoid redundant regex work.
|
||||||
*/
|
*/
|
||||||
processCleanData(cleanData: string): void {
|
processCleanData(cleanData: string): void {
|
||||||
|
|
||||||
// If tracker is disabled, only check for patterns that should auto-enable it
|
// If tracker is disabled, only check for patterns that should auto-enable it
|
||||||
if (!this._loopState.enabled) {
|
if (!this._loopState.enabled) {
|
||||||
// Don't auto-enable if explicitly disabled by user setting
|
// Don't auto-enable if explicitly disabled by user setting
|
||||||
@@ -1396,14 +1405,21 @@ export class RalphTracker extends EventEmitter {
|
|||||||
// substrings that any pattern could match are present in the data.
|
// substrings that any pattern could match are present in the data.
|
||||||
// This avoids 12 regex tests on every PTY chunk (the common case).
|
// This avoids 12 regex tests on every PTY chunk (the common case).
|
||||||
if (
|
if (
|
||||||
!data.includes('<') && // <promise>, TodoWrite
|
!data.includes('<') && // <promise>, TodoWrite
|
||||||
!data.includes('ralph') && !data.includes('Ralph') &&
|
!data.includes('ralph') &&
|
||||||
!data.includes('Todo') && !data.includes('todo') &&
|
!data.includes('Ralph') &&
|
||||||
!data.includes('Iteration') && !data.includes('[') &&
|
!data.includes('Todo') &&
|
||||||
!data.includes('\u2610') && !data.includes('\u2612') && // ☐ ☒
|
!data.includes('todo') &&
|
||||||
|
!data.includes('Iteration') &&
|
||||||
|
!data.includes('[') &&
|
||||||
|
!data.includes('\u2610') &&
|
||||||
|
!data.includes('\u2612') && // ☐ ☒
|
||||||
!data.includes('\u2714') && // ✔
|
!data.includes('\u2714') && // ✔
|
||||||
!data.includes('Loop') && !data.includes('complete') &&
|
!data.includes('Loop') &&
|
||||||
!data.includes('COMPLETE') && !data.includes('Done') && !data.includes('DONE')
|
!data.includes('complete') &&
|
||||||
|
!data.includes('COMPLETE') &&
|
||||||
|
!data.includes('Done') &&
|
||||||
|
!data.includes('DONE')
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1677,8 +1693,8 @@ export class RalphTracker extends EventEmitter {
|
|||||||
// Avoid false positives: don't trigger on prompt context
|
// Avoid false positives: don't trigger on prompt context
|
||||||
const isNotInPromptContext = !line.includes('<promise>') && !line.includes('output:');
|
const isNotInPromptContext = !line.includes('<promise>') && !line.includes('output:');
|
||||||
// Also avoid triggering on "completion phrase is X" explanatory text
|
// Also avoid triggering on "completion phrase is X" explanatory text
|
||||||
const isNotExplanation = !line.toLowerCase().includes('completion phrase') &&
|
const isNotExplanation =
|
||||||
!line.toLowerCase().includes('output exactly');
|
!line.toLowerCase().includes('completion phrase') && !line.toLowerCase().includes('output exactly');
|
||||||
|
|
||||||
if (isNotInPromptContext && isNotExplanation) {
|
if (isNotInPromptContext && isNotExplanation) {
|
||||||
this.handleBareCompletionPhrase(expectedPhrase);
|
this.handleBareCompletionPhrase(expectedPhrase);
|
||||||
@@ -1786,7 +1802,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
|
|
||||||
if (matchedPhrase) {
|
if (matchedPhrase) {
|
||||||
// Use the matched phrase (canonical) for tracking
|
// Use the matched phrase (canonical) for tracking
|
||||||
const canonicalCount = (this._completionPhraseCount.get(matchedPhrase) || 0);
|
const canonicalCount = this._completionPhraseCount.get(matchedPhrase) || 0;
|
||||||
// Require 2nd+ occurrence of canonical phrase OR explicitly active loop.
|
// Require 2nd+ occurrence of canonical phrase OR explicitly active loop.
|
||||||
// First occurrence (count=1) is the prompt echo — not actual completion.
|
// First occurrence (count=1) is the prompt echo — not actual completion.
|
||||||
if (canonicalCount >= 2 || this._loopState.active) {
|
if (canonicalCount >= 2 || this._loopState.active) {
|
||||||
@@ -1862,7 +1878,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
* @fires phraseValidationWarning - When a risky phrase is detected
|
* @fires phraseValidationWarning - When a risky phrase is detected
|
||||||
*/
|
*/
|
||||||
private validateCompletionPhrase(phrase: string): void {
|
private validateCompletionPhrase(phrase: string): void {
|
||||||
const normalized = phrase.toUpperCase().replace(/[\s_\-\.]+/g, '');
|
const normalized = phrase.toUpperCase().replace(/[\s_\-.]+/g, '');
|
||||||
|
|
||||||
// Generate a suggested unique phrase
|
// Generate a suggested unique phrase
|
||||||
const uniqueSuffix = Date.now().toString(36).slice(-4).toUpperCase();
|
const uniqueSuffix = Date.now().toString(36).slice(-4).toUpperCase();
|
||||||
@@ -1870,7 +1886,9 @@ export class RalphTracker extends EventEmitter {
|
|||||||
|
|
||||||
// Check for common phrases
|
// Check for common phrases
|
||||||
if (COMMON_COMPLETION_PHRASES.has(normalized)) {
|
if (COMMON_COMPLETION_PHRASES.has(normalized)) {
|
||||||
console.warn(`[RalphTracker] Warning: Completion phrase "${phrase}" is very common and may cause false positives. Consider using: "${suggestedPhrase}"`);
|
console.warn(
|
||||||
|
`[RalphTracker] Warning: Completion phrase "${phrase}" is very common and may cause false positives. Consider using: "${suggestedPhrase}"`
|
||||||
|
);
|
||||||
this.emit('phraseValidationWarning', {
|
this.emit('phraseValidationWarning', {
|
||||||
phrase,
|
phrase,
|
||||||
reason: 'common',
|
reason: 'common',
|
||||||
@@ -1881,7 +1899,9 @@ export class RalphTracker extends EventEmitter {
|
|||||||
|
|
||||||
// Check for short phrases
|
// Check for short phrases
|
||||||
if (normalized.length < MIN_RECOMMENDED_PHRASE_LENGTH) {
|
if (normalized.length < MIN_RECOMMENDED_PHRASE_LENGTH) {
|
||||||
console.warn(`[RalphTracker] Warning: Completion phrase "${phrase}" is too short (${normalized.length} chars). Consider using: "${suggestedPhrase}"`);
|
console.warn(
|
||||||
|
`[RalphTracker] Warning: Completion phrase "${phrase}" is too short (${normalized.length} chars). Consider using: "${suggestedPhrase}"`
|
||||||
|
);
|
||||||
this.emit('phraseValidationWarning', {
|
this.emit('phraseValidationWarning', {
|
||||||
phrase,
|
phrase,
|
||||||
reason: 'short',
|
reason: 'short',
|
||||||
@@ -1892,7 +1912,9 @@ export class RalphTracker extends EventEmitter {
|
|||||||
|
|
||||||
// Check for numeric-only phrases
|
// Check for numeric-only phrases
|
||||||
if (/^\d+$/.test(normalized)) {
|
if (/^\d+$/.test(normalized)) {
|
||||||
console.warn(`[RalphTracker] Warning: Completion phrase "${phrase}" is numeric-only and may cause false positives. Consider using: "${suggestedPhrase}"`);
|
console.warn(
|
||||||
|
`[RalphTracker] Warning: Completion phrase "${phrase}" is numeric-only and may cause false positives. Consider using: "${suggestedPhrase}"`
|
||||||
|
);
|
||||||
this.emit('phraseValidationWarning', {
|
this.emit('phraseValidationWarning', {
|
||||||
phrase,
|
phrase,
|
||||||
reason: 'numeric',
|
reason: 'numeric',
|
||||||
@@ -1971,14 +1993,16 @@ export class RalphTracker extends EventEmitter {
|
|||||||
if (currentIter !== this._lastObservedIteration) {
|
if (currentIter !== this._lastObservedIteration) {
|
||||||
this._lastIterationChangeTime = Date.now();
|
this._lastIterationChangeTime = Date.now();
|
||||||
this._lastObservedIteration = currentIter;
|
this._lastObservedIteration = currentIter;
|
||||||
this._iterationStallWarned = false; // Reset warning on iteration change
|
this._iterationStallWarned = false; // Reset warning on iteration change
|
||||||
|
|
||||||
// P1-004: Reset circuit breaker on successful iteration progress
|
// P1-004: Reset circuit breaker on successful iteration progress
|
||||||
// If we're making progress, the loop is healthy
|
// If we're making progress, the loop is healthy
|
||||||
if (this._circuitBreaker.state === 'HALF_OPEN' ||
|
if (
|
||||||
this._circuitBreaker.consecutiveNoProgress > 0 ||
|
this._circuitBreaker.state === 'HALF_OPEN' ||
|
||||||
this._circuitBreaker.consecutiveSameError > 0 ||
|
this._circuitBreaker.consecutiveNoProgress > 0 ||
|
||||||
this._circuitBreaker.consecutiveTestsFailure > 0) {
|
this._circuitBreaker.consecutiveSameError > 0 ||
|
||||||
|
this._circuitBreaker.consecutiveTestsFailure > 0
|
||||||
|
) {
|
||||||
this._circuitBreaker.consecutiveNoProgress = 0;
|
this._circuitBreaker.consecutiveNoProgress = 0;
|
||||||
this._circuitBreaker.consecutiveSameError = 0;
|
this._circuitBreaker.consecutiveSameError = 0;
|
||||||
this._circuitBreaker.lastProgressIteration = currentIter;
|
this._circuitBreaker.lastProgressIteration = currentIter;
|
||||||
@@ -2106,7 +2130,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
const content = match[2].trim();
|
const content = match[2].trim();
|
||||||
|
|
||||||
// Skip if content matches exclude patterns (tool invocations, commentary)
|
// Skip if content matches exclude patterns (tool invocations, commentary)
|
||||||
const shouldExclude = TODO_EXCLUDE_PATTERNS.some(pattern => pattern.test(content));
|
const shouldExclude = TODO_EXCLUDE_PATTERNS.some((pattern) => pattern.test(content));
|
||||||
if (shouldExclude) continue;
|
if (shouldExclude) continue;
|
||||||
|
|
||||||
// Skip if content is too short or looks like partial garbage
|
// Skip if content is too short or looks like partial garbage
|
||||||
@@ -2155,9 +2179,8 @@ export class RalphTracker extends EventEmitter {
|
|||||||
while ((match = TODO_TASK_STATUS_PATTERN.exec(line)) !== null) {
|
while ((match = TODO_TASK_STATUS_PATTERN.exec(line)) !== null) {
|
||||||
const taskNum = parseInt(match[1], 10);
|
const taskNum = parseInt(match[1], 10);
|
||||||
const statusStr = match[2].trim();
|
const statusStr = match[2].trim();
|
||||||
const status: RalphTodoStatus = statusStr === 'completed' ? 'completed'
|
const status: RalphTodoStatus =
|
||||||
: statusStr === 'in progress' ? 'in_progress'
|
statusStr === 'completed' ? 'completed' : statusStr === 'in progress' ? 'in_progress' : 'pending';
|
||||||
: 'pending';
|
|
||||||
const content = this._taskNumberToContent.get(taskNum);
|
const content = this._taskNumberToContent.get(taskNum);
|
||||||
if (content) {
|
if (content) {
|
||||||
this.upsertTodo(content, status);
|
this.upsertTodo(content, status);
|
||||||
@@ -2172,7 +2195,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
while ((match = TODO_PLAIN_CHECKMARK_PATTERN.exec(line)) !== null) {
|
while ((match = TODO_PLAIN_CHECKMARK_PATTERN.exec(line)) !== null) {
|
||||||
const content = match[1].trim();
|
const content = match[1].trim();
|
||||||
// Skip if content matches exclude patterns
|
// Skip if content matches exclude patterns
|
||||||
const shouldExclude = TODO_EXCLUDE_PATTERNS.some(pattern => pattern.test(content));
|
const shouldExclude = TODO_EXCLUDE_PATTERNS.some((pattern) => pattern.test(content));
|
||||||
if (shouldExclude) continue;
|
if (shouldExclude) continue;
|
||||||
if (content.length < 5) continue;
|
if (content.length < 5) continue;
|
||||||
// Skip status/created/updated prefixed content (already handled above)
|
// Skip status/created/updated prefixed content (already handled above)
|
||||||
@@ -2204,17 +2227,17 @@ export class RalphTracker extends EventEmitter {
|
|||||||
switch (icon) {
|
switch (icon) {
|
||||||
case '✓':
|
case '✓':
|
||||||
case '✅':
|
case '✅':
|
||||||
case '☒': // Claude Code checked checkbox
|
case '☒': // Claude Code checked checkbox
|
||||||
case '◉': // Filled circle (completed)
|
case '◉': // Filled circle (completed)
|
||||||
case '●': // Solid circle (completed)
|
case '●': // Solid circle (completed)
|
||||||
return 'completed';
|
return 'completed';
|
||||||
case '◐': // Half-filled circle (in progress)
|
case '◐': // Half-filled circle (in progress)
|
||||||
case '⏳':
|
case '⏳':
|
||||||
case '⌛':
|
case '⌛':
|
||||||
case '🔄':
|
case '🔄':
|
||||||
return 'in_progress';
|
return 'in_progress';
|
||||||
case '☐': // Claude Code empty checkbox
|
case '☐': // Claude Code empty checkbox
|
||||||
case '○': // Empty circle
|
case '○': // Empty circle
|
||||||
default:
|
default:
|
||||||
return 'pending';
|
return 'pending';
|
||||||
}
|
}
|
||||||
@@ -2280,10 +2303,10 @@ export class RalphTracker extends EventEmitter {
|
|||||||
|
|
||||||
// Clean content: remove ANSI codes, collapse whitespace, trim
|
// Clean content: remove ANSI codes, collapse whitespace, trim
|
||||||
const cleanContent = content
|
const cleanContent = content
|
||||||
.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '') // Remove ANSI escape codes
|
.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '') // Remove ANSI escape codes
|
||||||
.replace(/\s+/g, ' ') // Collapse whitespace
|
.replace(/\s+/g, ' ') // Collapse whitespace
|
||||||
.trim();
|
.trim();
|
||||||
if (cleanContent.length < 5) return; // Skip very short content
|
if (cleanContent.length < 5) return; // Skip very short content
|
||||||
|
|
||||||
// Parse priority from content
|
// Parse priority from content
|
||||||
const priority = this.parsePriority(cleanContent);
|
const priority = this.parsePriority(cleanContent);
|
||||||
@@ -2402,8 +2425,8 @@ export class RalphTracker extends EventEmitter {
|
|||||||
private normalizeTodoContent(content: string): string {
|
private normalizeTodoContent(content: string): string {
|
||||||
if (!content) return '';
|
if (!content) return '';
|
||||||
return content
|
return content
|
||||||
.replace(/\s+/g, ' ') // Collapse whitespace
|
.replace(/\s+/g, ' ') // Collapse whitespace
|
||||||
.replace(/[^a-zA-Z0-9\s.,!?'"-]/g, '') // Remove special chars (keep punctuation)
|
.replace(/[^a-zA-Z0-9\s.,!?'"-]/g, '') // Remove special chars (keep punctuation)
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -2507,7 +2530,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
if (normalized.length < 30) {
|
if (normalized.length < 30) {
|
||||||
threshold = 0.95; // Very strict for short strings
|
threshold = 0.95; // Very strict for short strings
|
||||||
} else if (normalized.length < 60) {
|
} else if (normalized.length < 60) {
|
||||||
threshold = 0.90; // Strict for medium strings
|
threshold = 0.9; // Strict for medium strings
|
||||||
} else {
|
} else {
|
||||||
threshold = TODO_SIMILARITY_THRESHOLD; // 0.85 for longer strings
|
threshold = TODO_SIMILARITY_THRESHOLD; // 0.85 for longer strings
|
||||||
}
|
}
|
||||||
@@ -2563,14 +2586,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Moderate: Bugs, features, enhancements
|
// Moderate: Bugs, features, enhancements
|
||||||
const moderatePatterns = [
|
const moderatePatterns = [/\bbug\b/, /\bfeature\b/, /\benhance(?:ment)?\b/, /\bimplement\b/, /\badd\b/, /\bfix\b/];
|
||||||
/\bbug\b/,
|
|
||||||
/\bfeature\b/,
|
|
||||||
/\benhance(?:ment)?\b/,
|
|
||||||
/\bimplement\b/,
|
|
||||||
/\badd\b/,
|
|
||||||
/\bfix\b/,
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const pattern of complexPatterns) {
|
for (const pattern of complexPatterns) {
|
||||||
if (pattern.test(lower)) return 'complex';
|
if (pattern.test(lower)) return 'complex';
|
||||||
@@ -2609,10 +2625,10 @@ export class RalphTracker extends EventEmitter {
|
|||||||
|
|
||||||
// Default estimates (in ms) based on typical task durations
|
// Default estimates (in ms) based on typical task durations
|
||||||
const defaults = {
|
const defaults = {
|
||||||
trivial: 1 * 60 * 1000, // 1 minute
|
trivial: 1 * 60 * 1000, // 1 minute
|
||||||
simple: 3 * 60 * 1000, // 3 minutes
|
simple: 3 * 60 * 1000, // 3 minutes
|
||||||
moderate: 10 * 60 * 1000, // 10 minutes
|
moderate: 10 * 60 * 1000, // 10 minutes
|
||||||
complex: 30 * 60 * 1000, // 30 minutes
|
complex: 30 * 60 * 1000, // 30 minutes
|
||||||
};
|
};
|
||||||
return defaults[complexity];
|
return defaults[complexity];
|
||||||
}
|
}
|
||||||
@@ -2671,9 +2687,9 @@ export class RalphTracker extends EventEmitter {
|
|||||||
public getTodoProgress(): RalphTodoProgress {
|
public getTodoProgress(): RalphTodoProgress {
|
||||||
const todos = Array.from(this._todos.values());
|
const todos = Array.from(this._todos.values());
|
||||||
const total = todos.length;
|
const total = todos.length;
|
||||||
const completed = todos.filter(t => t.status === 'completed').length;
|
const completed = todos.filter((t) => t.status === 'completed').length;
|
||||||
const inProgress = todos.filter(t => t.status === 'in_progress').length;
|
const inProgress = todos.filter((t) => t.status === 'in_progress').length;
|
||||||
const pending = todos.filter(t => t.status === 'pending').length;
|
const pending = todos.filter((t) => t.status === 'pending').length;
|
||||||
|
|
||||||
const percentComplete = total > 0 ? Math.round((completed / total) * 100) : 0;
|
const percentComplete = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||||
|
|
||||||
@@ -2983,7 +2999,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
// Ensure enabled flag exists (backwards compatibility)
|
// Ensure enabled flag exists (backwards compatibility)
|
||||||
this._loopState = {
|
this._loopState = {
|
||||||
...loopState,
|
...loopState,
|
||||||
enabled: loopState.enabled ?? false, // Override after spread for backwards compat
|
enabled: loopState.enabled ?? false, // Override after spread for backwards compat
|
||||||
};
|
};
|
||||||
this._todos.clear();
|
this._todos.clear();
|
||||||
for (const todo of todos) {
|
for (const todo of todos) {
|
||||||
@@ -3068,7 +3084,9 @@ export class RalphTracker extends EventEmitter {
|
|||||||
if (!Number.isNaN(value) && value >= 0) {
|
if (!Number.isNaN(value) && value >= 0) {
|
||||||
block.tasksCompletedThisLoop = value;
|
block.tasksCompletedThisLoop = value;
|
||||||
} else {
|
} else {
|
||||||
parseErrors.push(`Invalid TASKS_COMPLETED_THIS_LOOP value: "${tasksMatch[1]}". Expected: non-negative integer`);
|
parseErrors.push(
|
||||||
|
`Invalid TASKS_COMPLETED_THIS_LOOP value: "${tasksMatch[1]}". Expected: non-negative integer`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
matched = true;
|
matched = true;
|
||||||
}
|
}
|
||||||
@@ -3104,7 +3122,9 @@ export class RalphTracker extends EventEmitter {
|
|||||||
if (['IMPLEMENTATION', 'TESTING', 'DOCUMENTATION', 'REFACTORING'].includes(value)) {
|
if (['IMPLEMENTATION', 'TESTING', 'DOCUMENTATION', 'REFACTORING'].includes(value)) {
|
||||||
block.workType = value as RalphWorkType;
|
block.workType = value as RalphWorkType;
|
||||||
} else {
|
} else {
|
||||||
parseErrors.push(`Invalid WORK_TYPE value: "${value}". Expected: IMPLEMENTATION, TESTING, DOCUMENTATION, or REFACTORING`);
|
parseErrors.push(
|
||||||
|
`Invalid WORK_TYPE value: "${value}". Expected: IMPLEMENTATION, TESTING, DOCUMENTATION, or REFACTORING`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
matched = true;
|
matched = true;
|
||||||
}
|
}
|
||||||
@@ -3126,7 +3146,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
// Track unknown fields for debugging (only if looks like a field)
|
// Track unknown fields for debugging (only if looks like a field)
|
||||||
if (!matched && trimmedLine.includes(':')) {
|
if (!matched && trimmedLine.includes(':')) {
|
||||||
const fieldName = trimmedLine.split(':')[0].trim().toUpperCase();
|
const fieldName = trimmedLine.split(':')[0].trim().toUpperCase();
|
||||||
if (fieldName && !['#', '//'].some(c => fieldName.startsWith(c))) {
|
if (fieldName && !['#', '//'].some((c) => fieldName.startsWith(c))) {
|
||||||
unknownFields.push(fieldName);
|
unknownFields.push(fieldName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3221,11 +3241,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
* @param status - Overall status from RALPH_STATUS
|
* @param status - Overall status from RALPH_STATUS
|
||||||
* @fires circuitBreakerUpdate - If state changes
|
* @fires circuitBreakerUpdate - If state changes
|
||||||
*/
|
*/
|
||||||
private updateCircuitBreaker(
|
private updateCircuitBreaker(hasProgress: boolean, testsStatus: RalphTestsStatus, status: RalphStatusValue): void {
|
||||||
hasProgress: boolean,
|
|
||||||
testsStatus: RalphTestsStatus,
|
|
||||||
status: RalphStatusValue
|
|
||||||
): void {
|
|
||||||
const prevState = this._circuitBreaker.state;
|
const prevState = this._circuitBreaker.state;
|
||||||
|
|
||||||
if (hasProgress) {
|
if (hasProgress) {
|
||||||
@@ -3319,7 +3335,11 @@ export class RalphTracker extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Get cumulative stats from status blocks.
|
* Get cumulative stats from status blocks.
|
||||||
*/
|
*/
|
||||||
get cumulativeStats(): { filesModified: number; tasksCompleted: number; completionIndicators: number } {
|
get cumulativeStats(): {
|
||||||
|
filesModified: number;
|
||||||
|
tasksCompleted: number;
|
||||||
|
completionIndicators: number;
|
||||||
|
} {
|
||||||
return {
|
return {
|
||||||
filesModified: this._totalFilesModified,
|
filesModified: this._totalFilesModified,
|
||||||
tasksCompleted: this._totalTasksCompleted,
|
tasksCompleted: this._totalTasksCompleted,
|
||||||
@@ -3456,7 +3476,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
const tasksHeaderPattern = /^##\s*Tasks/i;
|
const tasksHeaderPattern = /^##\s*Tasks/i;
|
||||||
|
|
||||||
// Pattern for todo items
|
// Pattern for todo items
|
||||||
const todoPattern = /^-\s*\[([ x\-])\]\s*(.+)$/;
|
const todoPattern = /^-\s*\[([ x-])\]\s*(.+)$/;
|
||||||
|
|
||||||
let inCompletedSection = false;
|
let inCompletedSection = false;
|
||||||
|
|
||||||
@@ -3504,7 +3524,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse priority from content if not in a priority section
|
// Parse priority from content if not in a priority section
|
||||||
const parsedPriority = inCompletedSection ? null : (currentPriority || this.parsePriority(content));
|
const parsedPriority = inCompletedSection ? null : currentPriority || this.parsePriority(content);
|
||||||
|
|
||||||
const id = this.generateTodoId(content);
|
const id = this.generateTodoId(content);
|
||||||
newTodos.push({
|
newTodos.push({
|
||||||
@@ -3535,17 +3555,19 @@ export class RalphTracker extends EventEmitter {
|
|||||||
* Initialize plan tasks from generated plan items.
|
* Initialize plan tasks from generated plan items.
|
||||||
* Called when wizard generates a new plan.
|
* Called when wizard generates a new plan.
|
||||||
*/
|
*/
|
||||||
initializePlanTasks(items: Array<{
|
initializePlanTasks(
|
||||||
id?: string;
|
items: Array<{
|
||||||
content: string;
|
id?: string;
|
||||||
priority?: 'P0' | 'P1' | 'P2' | null;
|
content: string;
|
||||||
verificationCriteria?: string;
|
priority?: 'P0' | 'P1' | 'P2' | null;
|
||||||
testCommand?: string;
|
verificationCriteria?: string;
|
||||||
dependencies?: string[];
|
testCommand?: string;
|
||||||
tddPhase?: TddPhase;
|
dependencies?: string[];
|
||||||
pairedWith?: string;
|
tddPhase?: TddPhase;
|
||||||
complexity?: 'low' | 'medium' | 'high';
|
pairedWith?: string;
|
||||||
}>): void {
|
complexity?: 'low' | 'medium' | 'high';
|
||||||
|
}>
|
||||||
|
): void {
|
||||||
// Save current plan to history before replacing
|
// Save current plan to history before replacing
|
||||||
if (this._planTasks.size > 0) {
|
if (this._planTasks.size > 0) {
|
||||||
this._savePlanToHistory('Plan replaced with new generation');
|
this._savePlanToHistory('Plan replaced with new generation');
|
||||||
@@ -3580,11 +3602,14 @@ export class RalphTracker extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Update a specific plan task's status, attempts, or error.
|
* Update a specific plan task's status, attempts, or error.
|
||||||
*/
|
*/
|
||||||
updatePlanTask(taskId: string, update: {
|
updatePlanTask(
|
||||||
status?: PlanTaskStatus;
|
taskId: string,
|
||||||
error?: string;
|
update: {
|
||||||
incrementAttempts?: boolean;
|
status?: PlanTaskStatus;
|
||||||
}): { success: boolean; task?: EnhancedPlanTask; error?: string } {
|
error?: string;
|
||||||
|
incrementAttempts?: boolean;
|
||||||
|
}
|
||||||
|
): { success: boolean; task?: EnhancedPlanTask; error?: string } {
|
||||||
const task = this._planTasks.get(taskId);
|
const task = this._planTasks.get(taskId);
|
||||||
if (!task) {
|
if (!task) {
|
||||||
return { success: false, error: 'Task not found' };
|
return { success: false, error: 'Task not found' };
|
||||||
@@ -3635,7 +3660,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
for (const [_, task] of this._planTasks) {
|
for (const [_, task] of this._planTasks) {
|
||||||
if (task.dependencies.includes(completedTaskId)) {
|
if (task.dependencies.includes(completedTaskId)) {
|
||||||
// Check if all dependencies are now complete
|
// Check if all dependencies are now complete
|
||||||
const allDepsComplete = task.dependencies.every(depId => {
|
const allDepsComplete = task.dependencies.every((depId) => {
|
||||||
const dep = this._planTasks.get(depId);
|
const dep = this._planTasks.get(depId);
|
||||||
return dep && dep.status === 'completed';
|
return dep && dep.status === 'completed';
|
||||||
});
|
});
|
||||||
@@ -3653,8 +3678,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
private _checkForCheckpoint(): void {
|
private _checkForCheckpoint(): void {
|
||||||
const currentIteration = this._loopState.cycleCount;
|
const currentIteration = this._loopState.cycleCount;
|
||||||
if (this._checkpointIterations.includes(currentIteration) &&
|
if (this._checkpointIterations.includes(currentIteration) && currentIteration > this._lastCheckpointIteration) {
|
||||||
currentIteration > this._lastCheckpointIteration) {
|
|
||||||
this._lastCheckpointIteration = currentIteration;
|
this._lastCheckpointIteration = currentIteration;
|
||||||
const checkpoint = this.generateCheckpointReview();
|
const checkpoint = this.generateCheckpointReview();
|
||||||
this.emit('planCheckpoint', checkpoint);
|
this.emit('planCheckpoint', checkpoint);
|
||||||
@@ -3669,17 +3693,17 @@ export class RalphTracker extends EventEmitter {
|
|||||||
|
|
||||||
const summary = {
|
const summary = {
|
||||||
total: tasks.length,
|
total: tasks.length,
|
||||||
completed: tasks.filter(t => t.status === 'completed').length,
|
completed: tasks.filter((t) => t.status === 'completed').length,
|
||||||
failed: tasks.filter(t => t.status === 'failed').length,
|
failed: tasks.filter((t) => t.status === 'failed').length,
|
||||||
blocked: tasks.filter(t => t.status === 'blocked').length,
|
blocked: tasks.filter((t) => t.status === 'blocked').length,
|
||||||
pending: tasks.filter(t => t.status === 'pending').length,
|
pending: tasks.filter((t) => t.status === 'pending').length,
|
||||||
inProgress: tasks.filter(t => t.status === 'in_progress').length,
|
inProgress: tasks.filter((t) => t.status === 'in_progress').length,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Find stuck tasks (3+ attempts or blocked)
|
// Find stuck tasks (3+ attempts or blocked)
|
||||||
const stuckTasks = tasks
|
const stuckTasks = tasks
|
||||||
.filter(t => t.attempts >= 3 || t.status === 'blocked')
|
.filter((t) => t.attempts >= 3 || t.status === 'blocked')
|
||||||
.map(t => ({
|
.map((t) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
content: t.content,
|
content: t.content,
|
||||||
attempts: t.attempts,
|
attempts: t.attempts,
|
||||||
@@ -3697,9 +3721,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
recommendations.push('More tasks have failed than completed. Review approach and consider plan adjustment.');
|
recommendations.push('More tasks have failed than completed. Review approach and consider plan adjustment.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressPercent = summary.total > 0
|
const progressPercent = summary.total > 0 ? Math.round((summary.completed / summary.total) * 100) : 0;
|
||||||
? Math.round((summary.completed / summary.total) * 100)
|
|
||||||
: 0;
|
|
||||||
if (progressPercent < 20 && this._loopState.cycleCount > 10) {
|
if (progressPercent < 20 && this._loopState.cycleCount > 10) {
|
||||||
recommendations.push('Progress is slow. Consider simplifying tasks or reviewing dependencies.');
|
recommendations.push('Progress is slow. Consider simplifying tasks or reviewing dependencies.');
|
||||||
}
|
}
|
||||||
@@ -3749,7 +3771,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
summary: string;
|
summary: string;
|
||||||
stats: { total: number; completed: number; failed: number };
|
stats: { total: number; completed: number; failed: number };
|
||||||
}> {
|
}> {
|
||||||
return this._planHistory.map(h => {
|
return this._planHistory.map((h) => {
|
||||||
const tasks = Array.from(h.tasks.values());
|
const tasks = Array.from(h.tasks.values());
|
||||||
return {
|
return {
|
||||||
version: h.version,
|
version: h.version,
|
||||||
@@ -3757,8 +3779,8 @@ export class RalphTracker extends EventEmitter {
|
|||||||
summary: h.summary,
|
summary: h.summary,
|
||||||
stats: {
|
stats: {
|
||||||
total: tasks.length,
|
total: tasks.length,
|
||||||
completed: tasks.filter(t => t.status === 'completed').length,
|
completed: tasks.filter((t) => t.status === 'completed').length,
|
||||||
failed: tasks.filter(t => t.status === 'failed').length,
|
failed: tasks.filter((t) => t.status === 'failed').length,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -3767,8 +3789,12 @@ export class RalphTracker extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Rollback to a previous plan version.
|
* Rollback to a previous plan version.
|
||||||
*/
|
*/
|
||||||
rollbackToVersion(version: number): { success: boolean; plan?: EnhancedPlanTask[]; error?: string } {
|
rollbackToVersion(version: number): {
|
||||||
const historyEntry = this._planHistory.find(h => h.version === version);
|
success: boolean;
|
||||||
|
plan?: EnhancedPlanTask[];
|
||||||
|
error?: string;
|
||||||
|
} {
|
||||||
|
const historyEntry = this._planHistory.find((h) => h.version === version);
|
||||||
if (!historyEntry) {
|
if (!historyEntry) {
|
||||||
return { success: false, error: `Version ${version} not found in history` };
|
return { success: false, error: `Version ${version} not found in history` };
|
||||||
}
|
}
|
||||||
@@ -3807,7 +3833,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
// Generate unique ID
|
// Generate unique ID
|
||||||
const existingIds = Array.from(this._planTasks.keys());
|
const existingIds = Array.from(this._planTasks.keys());
|
||||||
const prefix = task.priority || 'P1';
|
const prefix = task.priority || 'P1';
|
||||||
let counter = existingIds.filter(id => id.startsWith(prefix)).length + 1;
|
let counter = existingIds.filter((id) => id.startsWith(prefix)).length + 1;
|
||||||
let id = `${prefix}-${String(counter).padStart(3, '0')}`;
|
let id = `${prefix}-${String(counter).padStart(3, '0')}`;
|
||||||
while (this._planTasks.has(id)) {
|
while (this._planTasks.has(id)) {
|
||||||
counter++;
|
counter++;
|
||||||
@@ -3850,8 +3876,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
isCheckpointDue(): boolean {
|
isCheckpointDue(): boolean {
|
||||||
const currentIteration = this._loopState.cycleCount;
|
const currentIteration = this._loopState.cycleCount;
|
||||||
return this._checkpointIterations.includes(currentIteration) &&
|
return this._checkpointIterations.includes(currentIteration) && currentIteration > this._lastCheckpointIteration;
|
||||||
currentIteration > this._lastCheckpointIteration;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -41,15 +41,8 @@ import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle-
|
|||||||
import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js';
|
import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js';
|
||||||
import type { TeamWatcher } from './team-watcher.js';
|
import type { TeamWatcher } from './team-watcher.js';
|
||||||
import { BufferAccumulator } from './utils/buffer-accumulator.js';
|
import { BufferAccumulator } from './utils/buffer-accumulator.js';
|
||||||
import {
|
import { ANSI_ESCAPE_PATTERN_SIMPLE, TOKEN_PATTERN, assertNever } from './utils/index.js';
|
||||||
ANSI_ESCAPE_PATTERN_SIMPLE,
|
import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js';
|
||||||
TOKEN_PATTERN,
|
|
||||||
assertNever,
|
|
||||||
} from './utils/index.js';
|
|
||||||
import {
|
|
||||||
MAX_RESPAWN_BUFFER_SIZE,
|
|
||||||
TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE,
|
|
||||||
} from './config/buffer-limits.js';
|
|
||||||
import type {
|
import type {
|
||||||
RespawnCycleMetrics,
|
RespawnCycleMetrics,
|
||||||
RespawnAggregateMetrics,
|
RespawnAggregateMetrics,
|
||||||
@@ -542,45 +535,45 @@ export interface RespawnEvents {
|
|||||||
|
|
||||||
/** Default configuration values */
|
/** Default configuration values */
|
||||||
const DEFAULT_CONFIG: RespawnConfig = {
|
const DEFAULT_CONFIG: RespawnConfig = {
|
||||||
idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback)
|
idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback)
|
||||||
updatePrompt: 'write a brief progress summary to CLAUDE.md noting what you accomplished, then continue working.',
|
updatePrompt: 'write a brief progress summary to CLAUDE.md noting what you accomplished, then continue working.',
|
||||||
interStepDelayMs: 1000, // 1 second between steps
|
interStepDelayMs: 1000, // 1 second between steps
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sendClear: true, // send /clear after update prompt
|
sendClear: true, // send /clear after update prompt
|
||||||
sendInit: true, // send /init after /clear
|
sendInit: true, // send /init after /clear
|
||||||
completionConfirmMs: 10000, // 10 seconds of silence after completion message
|
completionConfirmMs: 10000, // 10 seconds of silence after completion message
|
||||||
noOutputTimeoutMs: 30000, // 30 seconds fallback if no output at all
|
noOutputTimeoutMs: 30000, // 30 seconds fallback if no output at all
|
||||||
autoAcceptPrompts: true, // auto-accept plan mode prompts (not questions)
|
autoAcceptPrompts: true, // auto-accept plan mode prompts (not questions)
|
||||||
autoAcceptDelayMs: 8000, // 8 seconds before auto-accepting
|
autoAcceptDelayMs: 8000, // 8 seconds before auto-accepting
|
||||||
aiIdleCheckEnabled: true, // use AI to confirm idle state
|
aiIdleCheckEnabled: true, // use AI to confirm idle state
|
||||||
aiIdleCheckModel: 'claude-opus-4-5-20251101',
|
aiIdleCheckModel: 'claude-opus-4-5-20251101',
|
||||||
aiIdleCheckMaxContext: 16000, // ~4k tokens
|
aiIdleCheckMaxContext: 16000, // ~4k tokens
|
||||||
aiIdleCheckTimeoutMs: 90000, // 90 seconds (thinking can be slow)
|
aiIdleCheckTimeoutMs: 90000, // 90 seconds (thinking can be slow)
|
||||||
aiIdleCheckCooldownMs: 180000, // 3 minutes after WORKING verdict
|
aiIdleCheckCooldownMs: 180000, // 3 minutes after WORKING verdict
|
||||||
aiPlanCheckEnabled: true, // use AI to confirm plan mode before auto-accept
|
aiPlanCheckEnabled: true, // use AI to confirm plan mode before auto-accept
|
||||||
aiPlanCheckModel: 'claude-opus-4-5-20251101',
|
aiPlanCheckModel: 'claude-opus-4-5-20251101',
|
||||||
aiPlanCheckMaxContext: 8000, // ~2k tokens (plan mode UI is compact)
|
aiPlanCheckMaxContext: 8000, // ~2k tokens (plan mode UI is compact)
|
||||||
aiPlanCheckTimeoutMs: 60000, // 60 seconds (thinking can be slow)
|
aiPlanCheckTimeoutMs: 60000, // 60 seconds (thinking can be slow)
|
||||||
aiPlanCheckCooldownMs: 30000, // 30 seconds after NOT_PLAN_MODE
|
aiPlanCheckCooldownMs: 30000, // 30 seconds after NOT_PLAN_MODE
|
||||||
stuckStateDetectionEnabled: true, // detect stuck states
|
stuckStateDetectionEnabled: true, // detect stuck states
|
||||||
stuckStateWarningMs: 300000, // 5 minutes warning threshold
|
stuckStateWarningMs: 300000, // 5 minutes warning threshold
|
||||||
stuckStateRecoveryMs: 600000, // 10 minutes recovery threshold
|
stuckStateRecoveryMs: 600000, // 10 minutes recovery threshold
|
||||||
maxStuckRecoveries: 3, // max recovery attempts
|
maxStuckRecoveries: 3, // max recovery attempts
|
||||||
// P2-001: Adaptive timing
|
// P2-001: Adaptive timing
|
||||||
adaptiveTimingEnabled: true, // Use adaptive timing based on historical patterns
|
adaptiveTimingEnabled: true, // Use adaptive timing based on historical patterns
|
||||||
adaptiveMinConfirmMs: 5000, // Minimum 5 seconds
|
adaptiveMinConfirmMs: 5000, // Minimum 5 seconds
|
||||||
adaptiveMaxConfirmMs: 30000, // Maximum 30 seconds
|
adaptiveMaxConfirmMs: 30000, // Maximum 30 seconds
|
||||||
// P2-002: Skip-clear optimization
|
// P2-002: Skip-clear optimization
|
||||||
skipClearWhenLowContext: true, // Skip /clear when token count is low
|
skipClearWhenLowContext: true, // Skip /clear when token count is low
|
||||||
skipClearThresholdPercent: 30, // Skip if below 30% of max context
|
skipClearThresholdPercent: 30, // Skip if below 30% of max context
|
||||||
// P2-004: Cycle metrics
|
// P2-004: Cycle metrics
|
||||||
trackCycleMetrics: true, // Track and persist cycle metrics
|
trackCycleMetrics: true, // Track and persist cycle metrics
|
||||||
// P2-001: Confidence scoring
|
// P2-001: Confidence scoring
|
||||||
minIdleConfidence: 65, // Minimum confidence to trigger idle (0-100)
|
minIdleConfidence: 65, // Minimum confidence to trigger idle (0-100)
|
||||||
confidenceWeightCompletion: 40, // Weight for completion message
|
confidenceWeightCompletion: 40, // Weight for completion message
|
||||||
confidenceWeightSilence: 25, // Weight for output silence
|
confidenceWeightSilence: 25, // Weight for output silence
|
||||||
confidenceWeightTokens: 20, // Weight for token stability
|
confidenceWeightTokens: 20, // Weight for token stability
|
||||||
confidenceWeightNoWorking: 15, // Weight for working pattern absence
|
confidenceWeightNoWorking: 15, // Weight for working pattern absence
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -737,7 +730,8 @@ export class RespawnController extends EventEmitter {
|
|||||||
// ========== Timer Tracking for UI Countdown Display ==========
|
// ========== Timer Tracking for UI Countdown Display ==========
|
||||||
|
|
||||||
/** Active timers being tracked for UI display */
|
/** Active timers being tracked for UI display */
|
||||||
private activeTimers: Map<string, { name: string; startedAt: number; durationMs: number; endsAt: number }> = new Map();
|
private activeTimers: Map<string, { name: string; startedAt: number; durationMs: number; endsAt: number }> =
|
||||||
|
new Map();
|
||||||
|
|
||||||
/** Recent action log entries (for UI display, max 20) */
|
/** Recent action log entries (for UI display, max 20) */
|
||||||
private recentActions: ActionLogEntry[] = [];
|
private recentActions: ActionLogEntry[] = [];
|
||||||
@@ -818,9 +812,9 @@ export class RespawnController extends EventEmitter {
|
|||||||
* Used as secondary signals, not primary detection.
|
* Used as secondary signals, not primary detection.
|
||||||
*/
|
*/
|
||||||
private readonly PROMPT_PATTERNS = [
|
private readonly PROMPT_PATTERNS = [
|
||||||
'❯', // Standard prompt
|
'❯', // Standard prompt
|
||||||
'\u276f', // Unicode variant
|
'\u276f', // Unicode variant
|
||||||
'⏵', // Claude Code prompt variant
|
'⏵', // Claude Code prompt variant
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -829,15 +823,55 @@ export class RespawnController extends EventEmitter {
|
|||||||
* Note: ✻ and ✽ removed - they appear in completion messages too.
|
* Note: ✻ and ✽ removed - they appear in completion messages too.
|
||||||
*/
|
*/
|
||||||
private readonly WORKING_PATTERNS = [
|
private readonly WORKING_PATTERNS = [
|
||||||
'Thinking', 'Writing', 'Reading', 'Running', 'Searching',
|
'Thinking',
|
||||||
'Editing', 'Creating', 'Deleting', 'Analyzing', 'Executing',
|
'Writing',
|
||||||
'Synthesizing', 'Brewing', // Claude's processing indicators
|
'Reading',
|
||||||
'Compiling', 'Building', 'Installing', 'Fetching', 'Downloading',
|
'Running',
|
||||||
'Processing', 'Generating', 'Loading', 'Starting', 'Updating',
|
'Searching',
|
||||||
'Checking', 'Validating', 'Testing', 'Formatting', 'Linting',
|
'Editing',
|
||||||
'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏', // Spinner chars
|
'Creating',
|
||||||
'◐', '◓', '◑', '◒', // Alternative spinners
|
'Deleting',
|
||||||
'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷', // Braille spinners
|
'Analyzing',
|
||||||
|
'Executing',
|
||||||
|
'Synthesizing',
|
||||||
|
'Brewing', // Claude's processing indicators
|
||||||
|
'Compiling',
|
||||||
|
'Building',
|
||||||
|
'Installing',
|
||||||
|
'Fetching',
|
||||||
|
'Downloading',
|
||||||
|
'Processing',
|
||||||
|
'Generating',
|
||||||
|
'Loading',
|
||||||
|
'Starting',
|
||||||
|
'Updating',
|
||||||
|
'Checking',
|
||||||
|
'Validating',
|
||||||
|
'Testing',
|
||||||
|
'Formatting',
|
||||||
|
'Linting',
|
||||||
|
'⠋',
|
||||||
|
'⠙',
|
||||||
|
'⠹',
|
||||||
|
'⠸',
|
||||||
|
'⠼',
|
||||||
|
'⠴',
|
||||||
|
'⠦',
|
||||||
|
'⠧',
|
||||||
|
'⠇',
|
||||||
|
'⠏', // Spinner chars
|
||||||
|
'◐',
|
||||||
|
'◓',
|
||||||
|
'◑',
|
||||||
|
'◒', // Alternative spinners
|
||||||
|
'⣾',
|
||||||
|
'⣽',
|
||||||
|
'⣻',
|
||||||
|
'⢿',
|
||||||
|
'⡿',
|
||||||
|
'⣟',
|
||||||
|
'⣯',
|
||||||
|
'⣷', // Braille spinners
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1200,7 +1234,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
const prevState = this._state;
|
const prevState = this._state;
|
||||||
this._state = newState;
|
this._state = newState;
|
||||||
this.stateEnteredAt = Date.now();
|
this.stateEnteredAt = Date.now();
|
||||||
this.stuckStateWarned = false; // Reset warning for new state
|
this.stuckStateWarned = false; // Reset warning for new state
|
||||||
this.log(`State: ${prevState} → ${newState}`);
|
this.log(`State: ${prevState} → ${newState}`);
|
||||||
this.logAction('state', `${prevState} → ${newState}`);
|
this.logAction('state', `${prevState} → ${newState}`);
|
||||||
this.emit('stateChanged', newState, prevState);
|
this.emit('stateChanged', newState, prevState);
|
||||||
@@ -1518,7 +1552,9 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.setState('watching');
|
this.setState('watching');
|
||||||
} else {
|
} else {
|
||||||
// Real content (not just escape codes or single chars) - cancel confirmation
|
// Real content (not just escape codes or single chars) - cancel confirmation
|
||||||
this.log(`Substantial output during confirmation ("${stripped.substring(0, 40)}..."), cancelling idle detection`);
|
this.log(
|
||||||
|
`Substantial output during confirmation ("${stripped.substring(0, 40)}..."), cancelling idle detection`
|
||||||
|
);
|
||||||
this.cancelCompletionConfirm();
|
this.cancelCompletionConfirm();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -1526,7 +1562,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Legacy fallback: detect prompt characters (still useful for waiting_* states)
|
// Legacy fallback: detect prompt characters (still useful for waiting_* states)
|
||||||
const hasPrompt = this.PROMPT_PATTERNS.some(pattern => data.includes(pattern));
|
const hasPrompt = this.PROMPT_PATTERNS.some((pattern) => data.includes(pattern));
|
||||||
if (hasPrompt) {
|
if (hasPrompt) {
|
||||||
this.promptDetected = true;
|
this.promptDetected = true;
|
||||||
this.workingDetected = false;
|
this.workingDetected = false;
|
||||||
@@ -1706,7 +1742,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
if (this._state === 'stopped') return;
|
if (this._state === 'stopped') return;
|
||||||
const prompt = this.config.kickstartPrompt!;
|
const prompt = this.config.kickstartPrompt!;
|
||||||
this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`);
|
this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`);
|
||||||
await this.session.writeViaMux(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
|
await this.session.writeViaMux(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
|
||||||
this.emit('stepSent', 'kickstart', prompt);
|
this.emit('stepSent', 'kickstart', prompt);
|
||||||
this.setState('waiting_kickstart');
|
this.setState('waiting_kickstart');
|
||||||
this.promptDetected = false;
|
this.promptDetected = false;
|
||||||
@@ -1831,11 +1867,16 @@ export class RespawnController extends EventEmitter {
|
|||||||
if (this.stuckRecoveryCount < this.config.maxStuckRecoveries) {
|
if (this.stuckRecoveryCount < this.config.maxStuckRecoveries) {
|
||||||
this.stuckRecoveryCount++;
|
this.stuckRecoveryCount++;
|
||||||
this.logAction('stuck', `Recovery attempt ${this.stuckRecoveryCount}/${this.config.maxStuckRecoveries}`);
|
this.logAction('stuck', `Recovery attempt ${this.stuckRecoveryCount}/${this.config.maxStuckRecoveries}`);
|
||||||
this.log(`Stuck-state recovery triggered (state: ${this._state}, duration: ${Math.round(durationMs / 1000)}s, attempt: ${this.stuckRecoveryCount})`);
|
this.log(
|
||||||
|
`Stuck-state recovery triggered (state: ${this._state}, duration: ${Math.round(durationMs / 1000)}s, attempt: ${this.stuckRecoveryCount})`
|
||||||
|
);
|
||||||
this.emit('stuckStateRecovery', this._state, durationMs, this.stuckRecoveryCount);
|
this.emit('stuckStateRecovery', this._state, durationMs, this.stuckRecoveryCount);
|
||||||
this.handleStuckStateRecovery();
|
this.handleStuckStateRecovery();
|
||||||
} else {
|
} else {
|
||||||
this.logAction('stuck', `Max recoveries (${this.config.maxStuckRecoveries}) reached - manual intervention needed`);
|
this.logAction(
|
||||||
|
'stuck',
|
||||||
|
`Max recoveries (${this.config.maxStuckRecoveries}) reached - manual intervention needed`
|
||||||
|
);
|
||||||
this.log(`Stuck-state: max recoveries reached, manual intervention needed`);
|
this.log(`Stuck-state: max recoveries reached, manual intervention needed`);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -1950,12 +1991,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
* Start a tracked timer with UI countdown support.
|
* Start a tracked timer with UI countdown support.
|
||||||
* Emits timerStarted event and tracks the timer for UI display.
|
* Emits timerStarted event and tracks the timer for UI display.
|
||||||
*/
|
*/
|
||||||
private startTrackedTimer(
|
private startTrackedTimer(name: string, durationMs: number, callback: () => void, reason?: string): NodeJS.Timeout {
|
||||||
name: string,
|
|
||||||
durationMs: number,
|
|
||||||
callback: () => void,
|
|
||||||
reason?: string
|
|
||||||
): NodeJS.Timeout {
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const endsAt = now + durationMs;
|
const endsAt = now + durationMs;
|
||||||
|
|
||||||
@@ -1989,7 +2025,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
getActiveTimers(): ActiveTimerInfo[] {
|
getActiveTimers(): ActiveTimerInfo[] {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return Array.from(this.activeTimers.values()).map(t => ({
|
return Array.from(this.activeTimers.values()).map((t) => ({
|
||||||
name: t.name,
|
name: t.name,
|
||||||
remainingMs: Math.max(0, t.endsAt - now),
|
remainingMs: Math.max(0, t.endsAt - now),
|
||||||
totalMs: t.durationMs,
|
totalMs: t.durationMs,
|
||||||
@@ -2038,7 +2074,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check the rolling window (includes current data, catches both complete and split patterns)
|
// Check the rolling window (includes current data, catches both complete and split patterns)
|
||||||
return this.WORKING_PATTERNS.some(pattern => this.workingPatternWindow.includes(pattern));
|
return this.WORKING_PATTERNS.some((pattern) => this.workingPatternWindow.includes(pattern));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2175,7 +2211,9 @@ export class RespawnController extends EventEmitter {
|
|||||||
|
|
||||||
// If on cooldown, don't start check - wait for cooldown to expire
|
// If on cooldown, don't start check - wait for cooldown to expire
|
||||||
if (this.aiChecker.isOnCooldown()) {
|
if (this.aiChecker.isOnCooldown()) {
|
||||||
this.log(`AI check on cooldown (${Math.ceil(this.aiChecker.getCooldownRemainingMs() / 1000)}s remaining), waiting...`);
|
this.log(
|
||||||
|
`AI check on cooldown (${Math.ceil(this.aiChecker.getCooldownRemainingMs() / 1000)}s remaining), waiting...`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2207,67 +2245,70 @@ export class RespawnController extends EventEmitter {
|
|||||||
// Get the terminal buffer for analysis
|
// Get the terminal buffer for analysis
|
||||||
const buffer = this.terminalBuffer.value;
|
const buffer = this.terminalBuffer.value;
|
||||||
|
|
||||||
this.aiChecker.check(buffer).then((result) => {
|
this.aiChecker
|
||||||
// If state changed while checking (e.g., cancelled), ignore result
|
.check(buffer)
|
||||||
if (this._state !== 'ai_checking') {
|
.then((result) => {
|
||||||
this.log(`AI check result ignored (state is now ${this._state})`);
|
// If state changed while checking (e.g., cancelled), ignore result
|
||||||
return;
|
if (this._state !== 'ai_checking') {
|
||||||
}
|
this.log(`AI check result ignored (state is now ${this._state})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate this is the result for the current check (not a stale one)
|
// Validate this is the result for the current check (not a stale one)
|
||||||
if (this._currentAiCheckId !== checkId) {
|
if (this._currentAiCheckId !== checkId) {
|
||||||
this.log(`AI check result ignored (stale check ID: ${checkId.substring(0, 8)})`);
|
this.log(`AI check result ignored (stale check ID: ${checkId.substring(0, 8)})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.verdict === 'IDLE') {
|
if (result.verdict === 'IDLE') {
|
||||||
// Cancel any pending confirmation timers - AI has spoken
|
// Cancel any pending confirmation timers - AI has spoken
|
||||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: IDLE');
|
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: IDLE');
|
||||||
this.completionConfirmTimer = null;
|
this.completionConfirmTimer = null;
|
||||||
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'AI verdict: IDLE');
|
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'AI verdict: IDLE');
|
||||||
this.preFilterTimer = null;
|
this.preFilterTimer = null;
|
||||||
|
|
||||||
this.logAction('ai-check', `Verdict: IDLE - ${result.reasoning}`);
|
this.logAction('ai-check', `Verdict: IDLE - ${result.reasoning}`);
|
||||||
this.emit('aiCheckCompleted', result);
|
this.emit('aiCheckCompleted', result);
|
||||||
this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`);
|
this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`);
|
||||||
} else if (result.verdict === 'WORKING') {
|
} else if (result.verdict === 'WORKING') {
|
||||||
// Cancel timers and go to cooldown
|
// Cancel timers and go to cooldown
|
||||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: WORKING');
|
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: WORKING');
|
||||||
this.completionConfirmTimer = null;
|
this.completionConfirmTimer = null;
|
||||||
|
|
||||||
this.logAction('ai-check', `Verdict: WORKING - ${result.reasoning}`);
|
this.logAction('ai-check', `Verdict: WORKING - ${result.reasoning}`);
|
||||||
this.emit('aiCheckCompleted', result);
|
this.emit('aiCheckCompleted', result);
|
||||||
this.setState('watching');
|
this.setState('watching');
|
||||||
this.log(`AI check says WORKING, returning to watching with ${this.config.aiIdleCheckCooldownMs}ms cooldown`);
|
this.log(`AI check says WORKING, returning to watching with ${this.config.aiIdleCheckCooldownMs}ms cooldown`);
|
||||||
// Restart timers so the controller retries after cooldown expires
|
// Restart timers so the controller retries after cooldown expires
|
||||||
this.startNoOutputTimer();
|
this.startNoOutputTimer();
|
||||||
this.startPreFilterTimer();
|
this.startPreFilterTimer();
|
||||||
} else {
|
} else {
|
||||||
// ERROR verdict
|
// ERROR verdict
|
||||||
this.logAction('ai-check', `Error: ${result.reasoning}`);
|
this.logAction('ai-check', `Error: ${result.reasoning}`);
|
||||||
this.emit('aiCheckFailed', result.reasoning);
|
this.emit('aiCheckFailed', result.reasoning);
|
||||||
this.setState('watching');
|
this.setState('watching');
|
||||||
// Restart timers to allow retry
|
// Restart timers to allow retry
|
||||||
this.startNoOutputTimer();
|
this.startNoOutputTimer();
|
||||||
this.startPreFilterTimer();
|
this.startPreFilterTimer();
|
||||||
}
|
}
|
||||||
}).catch((err) => {
|
})
|
||||||
// Validate this is the error for the current check
|
.catch((err) => {
|
||||||
if (this._currentAiCheckId !== checkId) {
|
// Validate this is the error for the current check
|
||||||
return; // Stale check, ignore error
|
if (this._currentAiCheckId !== checkId) {
|
||||||
}
|
return; // Stale check, ignore error
|
||||||
if (this._state === 'stopped') return; // Guard against stopped state
|
}
|
||||||
if (this._state === 'ai_checking') {
|
if (this._state === 'stopped') return; // Guard against stopped state
|
||||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
if (this._state === 'ai_checking') {
|
||||||
this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`);
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
this.emit('aiCheckFailed', errorMsg);
|
this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`);
|
||||||
this.setState('watching');
|
this.emit('aiCheckFailed', errorMsg);
|
||||||
this.log(`AI check error: ${errorMsg}`);
|
this.setState('watching');
|
||||||
// Restart timers to allow retry
|
this.log(`AI check error: ${errorMsg}`);
|
||||||
this.startNoOutputTimer();
|
// Restart timers to allow retry
|
||||||
this.startPreFilterTimer();
|
this.startNoOutputTimer();
|
||||||
}
|
this.startPreFilterTimer();
|
||||||
});
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Auto-Accept Prompt Methods ==========
|
// ========== Auto-Accept Prompt Methods ==========
|
||||||
@@ -2351,7 +2392,9 @@ export class RespawnController extends EventEmitter {
|
|||||||
// Stage 2: AI confirmation (if enabled and available)
|
// Stage 2: AI confirmation (if enabled and available)
|
||||||
if (this.config.aiPlanCheckEnabled && this.planChecker.status !== 'disabled') {
|
if (this.config.aiPlanCheckEnabled && this.planChecker.status !== 'disabled') {
|
||||||
if (this.planChecker.isOnCooldown()) {
|
if (this.planChecker.isOnCooldown()) {
|
||||||
this.log(`Skipping auto-accept: plan checker on cooldown (${Math.ceil(this.planChecker.getCooldownRemainingMs() / 1000)}s remaining)`);
|
this.log(
|
||||||
|
`Skipping auto-accept: plan checker on cooldown (${Math.ceil(this.planChecker.getCooldownRemainingMs() / 1000)}s remaining)`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.planChecker.status === 'checking') {
|
if (this.planChecker.status === 'checking') {
|
||||||
@@ -2396,7 +2439,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
// Working patterns before the selector are from earlier work and don't matter.
|
// Working patterns before the selector are from earlier work and don't matter.
|
||||||
const selectorIndex = stripped.lastIndexOf(selectorMatch[0]);
|
const selectorIndex = stripped.lastIndexOf(selectorMatch[0]);
|
||||||
const afterSelector = stripped.slice(selectorIndex + selectorMatch[0].length);
|
const afterSelector = stripped.slice(selectorIndex + selectorMatch[0].length);
|
||||||
const hasWorking = this.WORKING_PATTERNS.some(pattern => afterSelector.includes(pattern));
|
const hasWorking = this.WORKING_PATTERNS.some((pattern) => afterSelector.includes(pattern));
|
||||||
if (hasWorking) return false;
|
if (hasWorking) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -2416,36 +2459,39 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.logAction('plan-check', 'Spawning AI plan checker');
|
this.logAction('plan-check', 'Spawning AI plan checker');
|
||||||
this.emit('planCheckStarted');
|
this.emit('planCheckStarted');
|
||||||
|
|
||||||
this.planChecker.check(buffer).then((result) => {
|
this.planChecker
|
||||||
// Discard stale result if new output arrived during check
|
.check(buffer)
|
||||||
if (this.lastOutputTime > this.planCheckStartTime) {
|
.then((result) => {
|
||||||
this.logAction('plan-check', 'Result discarded (output arrived during check)');
|
// Discard stale result if new output arrived during check
|
||||||
return;
|
if (this.lastOutputTime > this.planCheckStartTime) {
|
||||||
}
|
this.logAction('plan-check', 'Result discarded (output arrived during check)');
|
||||||
|
|
||||||
if (result.verdict === 'PLAN_MODE') {
|
|
||||||
// Don't send Enter if state changed (e.g., AI idle check started or respawn cycle began)
|
|
||||||
if (this._state !== 'watching') {
|
|
||||||
this.logAction('plan-check', `Verdict: PLAN_MODE but state is ${this._state}, not sending Enter`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.emit('planCheckCompleted', result);
|
|
||||||
this.logAction('plan-check', 'Verdict: PLAN_MODE - sending Enter immediately');
|
if (result.verdict === 'PLAN_MODE') {
|
||||||
this.sendAutoAcceptEnter();
|
// Don't send Enter if state changed (e.g., AI idle check started or respawn cycle began)
|
||||||
// No cooldown needed - we're taking action
|
if (this._state !== 'watching') {
|
||||||
} else if (result.verdict === 'NOT_PLAN_MODE') {
|
this.logAction('plan-check', `Verdict: PLAN_MODE but state is ${this._state}, not sending Enter`);
|
||||||
this.emit('planCheckCompleted', result);
|
return;
|
||||||
this.logAction('plan-check', `Verdict: NOT_PLAN_MODE - ${result.reasoning}`);
|
}
|
||||||
} else {
|
this.emit('planCheckCompleted', result);
|
||||||
// ERROR verdict
|
this.logAction('plan-check', 'Verdict: PLAN_MODE - sending Enter immediately');
|
||||||
this.emit('planCheckFailed', result.reasoning);
|
this.sendAutoAcceptEnter();
|
||||||
this.logAction('plan-check', `Error: ${result.reasoning}`);
|
// No cooldown needed - we're taking action
|
||||||
}
|
} else if (result.verdict === 'NOT_PLAN_MODE') {
|
||||||
}).catch((err) => {
|
this.emit('planCheckCompleted', result);
|
||||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
this.logAction('plan-check', `Verdict: NOT_PLAN_MODE - ${result.reasoning}`);
|
||||||
this.emit('planCheckFailed', errorMsg);
|
} else {
|
||||||
this.logAction('plan-check', `Failed: ${errorMsg.substring(0, 50)}`);
|
// ERROR verdict
|
||||||
});
|
this.emit('planCheckFailed', result.reasoning);
|
||||||
|
this.logAction('plan-check', `Error: ${result.reasoning}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
this.emit('planCheckFailed', errorMsg);
|
||||||
|
this.logAction('plan-check', `Failed: ${errorMsg.substring(0, 50)}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2765,11 +2811,13 @@ export class RespawnController extends EventEmitter {
|
|||||||
|
|
||||||
this.log(`Idle confirmed via: ${reason}`);
|
this.log(`Idle confirmed via: ${reason}`);
|
||||||
const status = this.getDetectionStatus();
|
const status = this.getDetectionStatus();
|
||||||
this.log(`Detection status: confidence=${status.confidenceLevel}%, ` +
|
this.log(
|
||||||
`completion=${status.completionMessageDetected}, ` +
|
`Detection status: confidence=${status.confidenceLevel}%, ` +
|
||||||
`silent=${status.outputSilent}, ` +
|
`completion=${status.completionMessageDetected}, ` +
|
||||||
`tokensStable=${status.tokensStable}, ` +
|
`silent=${status.outputSilent}, ` +
|
||||||
`noWorking=${status.workingPatternsAbsent}`);
|
`tokensStable=${status.tokensStable}, ` +
|
||||||
|
`noWorking=${status.workingPatternsAbsent}`
|
||||||
|
);
|
||||||
|
|
||||||
// ========== Agent Teams Integration ==========
|
// ========== Agent Teams Integration ==========
|
||||||
// Check if session has active teammates — don't respawn while team is working
|
// Check if session has active teammates — don't respawn while team is working
|
||||||
@@ -2777,7 +2825,10 @@ export class RespawnController extends EventEmitter {
|
|||||||
const count = this.teamWatcher.getActiveTeammateCount(this.session.id);
|
const count = this.teamWatcher.getActiveTeammateCount(this.session.id);
|
||||||
this.log(`Respawn blocked - ${count} active teammate(s) working`);
|
this.log(`Respawn blocked - ${count} active teammate(s) working`);
|
||||||
this.logAction('team', `Active teammates: ${count}`);
|
this.logAction('team', `Active teammates: ${count}`);
|
||||||
this.emit('respawnBlocked', { reason: 'active_teammates', details: `${count} teammate(s) still working` });
|
this.emit('respawnBlocked', {
|
||||||
|
reason: 'active_teammates',
|
||||||
|
details: `${count} teammate(s) still working`,
|
||||||
|
});
|
||||||
this.setState('watching');
|
this.setState('watching');
|
||||||
this.startNoOutputTimer();
|
this.startNoOutputTimer();
|
||||||
this.startPreFilterTimer();
|
this.startPreFilterTimer();
|
||||||
@@ -2792,7 +2843,10 @@ export class RespawnController extends EventEmitter {
|
|||||||
if (circuitBreaker.state === 'OPEN') {
|
if (circuitBreaker.state === 'OPEN') {
|
||||||
this.log(`Respawn blocked - Circuit breaker OPEN: ${circuitBreaker.reason}`);
|
this.log(`Respawn blocked - Circuit breaker OPEN: ${circuitBreaker.reason}`);
|
||||||
this.logAction('ralph', `Circuit breaker OPEN: ${circuitBreaker.reason}`);
|
this.logAction('ralph', `Circuit breaker OPEN: ${circuitBreaker.reason}`);
|
||||||
this.emit('respawnBlocked', { reason: 'circuit_breaker_open', details: circuitBreaker.reason });
|
this.emit('respawnBlocked', {
|
||||||
|
reason: 'circuit_breaker_open',
|
||||||
|
details: circuitBreaker.reason,
|
||||||
|
});
|
||||||
this.setState('watching');
|
this.setState('watching');
|
||||||
// Don't restart timers - wait for manual reset or circuit breaker resolution
|
// Don't restart timers - wait for manual reset or circuit breaker resolution
|
||||||
return;
|
return;
|
||||||
@@ -2804,7 +2858,10 @@ export class RespawnController extends EventEmitter {
|
|||||||
if (statusBlock?.exitSignal) {
|
if (statusBlock?.exitSignal) {
|
||||||
this.log(`Respawn paused - RALPH_STATUS EXIT_SIGNAL=true`);
|
this.log(`Respawn paused - RALPH_STATUS EXIT_SIGNAL=true`);
|
||||||
this.logAction('ralph', `Exit signal detected: ${statusBlock.recommendation || 'Task complete'}`);
|
this.logAction('ralph', `Exit signal detected: ${statusBlock.recommendation || 'Task complete'}`);
|
||||||
this.emit('respawnBlocked', { reason: 'exit_signal', details: statusBlock.recommendation || 'Task complete' });
|
this.emit('respawnBlocked', {
|
||||||
|
reason: 'exit_signal',
|
||||||
|
details: statusBlock.recommendation || 'Task complete',
|
||||||
|
});
|
||||||
this.setState('watching');
|
this.setState('watching');
|
||||||
// Don't restart timers - loop is complete
|
// Don't restart timers - loop is complete
|
||||||
return;
|
return;
|
||||||
@@ -2814,7 +2871,10 @@ export class RespawnController extends EventEmitter {
|
|||||||
if (statusBlock?.status === 'BLOCKED') {
|
if (statusBlock?.status === 'BLOCKED') {
|
||||||
this.log(`Respawn blocked - RALPH_STATUS reports BLOCKED`);
|
this.log(`Respawn blocked - RALPH_STATUS reports BLOCKED`);
|
||||||
this.logAction('ralph', `Claude reported BLOCKED: ${statusBlock.recommendation || 'Needs human intervention'}`);
|
this.logAction('ralph', `Claude reported BLOCKED: ${statusBlock.recommendation || 'Needs human intervention'}`);
|
||||||
this.emit('respawnBlocked', { reason: 'status_blocked', details: statusBlock.recommendation || 'Needs human intervention' });
|
this.emit('respawnBlocked', {
|
||||||
|
reason: 'status_blocked',
|
||||||
|
details: statusBlock.recommendation || 'Needs human intervention',
|
||||||
|
});
|
||||||
this.setState('watching');
|
this.setState('watching');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2846,7 +2906,10 @@ export class RespawnController extends EventEmitter {
|
|||||||
if (this.session.status === 'error') {
|
if (this.session.status === 'error') {
|
||||||
this.log('Skipping respawn cycle - session is in error state');
|
this.log('Skipping respawn cycle - session is in error state');
|
||||||
this.logAction('health', 'Respawn skipped: Session error state');
|
this.logAction('health', 'Respawn skipped: Session error state');
|
||||||
this.emit('respawnBlocked', { reason: 'session_error', details: 'Session is in error state' });
|
this.emit('respawnBlocked', {
|
||||||
|
reason: 'session_error',
|
||||||
|
details: 'Session is in error state',
|
||||||
|
});
|
||||||
this.setState('watching');
|
this.setState('watching');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2907,7 +2970,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.logAction('ralph', `Using RECOMMENDATION: ${rec.substring(0, 50)}...`);
|
this.logAction('ralph', `Using RECOMMENDATION: ${rec.substring(0, 50)}...`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const input = updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI
|
const input = updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI
|
||||||
this.logAction('command', `Sending: "${updatePrompt.substring(0, 50)}..."`);
|
this.logAction('command', `Sending: "${updatePrompt.substring(0, 50)}..."`);
|
||||||
await this.session.writeViaMux(input);
|
await this.session.writeViaMux(input);
|
||||||
this.emit('stepSent', 'update', updatePrompt);
|
this.emit('stepSent', 'update', updatePrompt);
|
||||||
@@ -2937,7 +3000,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.stepTimer = null;
|
this.stepTimer = null;
|
||||||
if (this._state === 'stopped') return;
|
if (this._state === 'stopped') return;
|
||||||
this.logAction('command', 'Sending: /clear');
|
this.logAction('command', 'Sending: /clear');
|
||||||
await this.session.writeViaMux('/clear\r'); // \r triggers Enter in Ink/Claude CLI
|
await this.session.writeViaMux('/clear\r'); // \r triggers Enter in Ink/Claude CLI
|
||||||
this.emit('stepSent', 'clear', '/clear');
|
this.emit('stepSent', 'clear', '/clear');
|
||||||
this.setState('waiting_clear');
|
this.setState('waiting_clear');
|
||||||
this.promptDetected = false;
|
this.promptDetected = false;
|
||||||
@@ -2981,7 +3044,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.stepTimer = null;
|
this.stepTimer = null;
|
||||||
if (this._state === 'stopped') return;
|
if (this._state === 'stopped') return;
|
||||||
this.logAction('command', 'Sending: /init');
|
this.logAction('command', 'Sending: /init');
|
||||||
await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI
|
await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI
|
||||||
this.emit('stepSent', 'init', '/init');
|
this.emit('stepSent', 'init', '/init');
|
||||||
this.setState('waiting_init');
|
this.setState('waiting_init');
|
||||||
this.promptDetected = false;
|
this.promptDetected = false;
|
||||||
@@ -3052,9 +3115,13 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.config = { ...this.config, ...filteredConfig };
|
this.config = { ...this.config, ...filteredConfig };
|
||||||
|
|
||||||
// Sync AI checker config if relevant fields changed
|
// Sync AI checker config if relevant fields changed
|
||||||
if (config.aiIdleCheckEnabled !== undefined || config.aiIdleCheckModel !== undefined ||
|
if (
|
||||||
config.aiIdleCheckMaxContext !== undefined || config.aiIdleCheckTimeoutMs !== undefined ||
|
config.aiIdleCheckEnabled !== undefined ||
|
||||||
config.aiIdleCheckCooldownMs !== undefined) {
|
config.aiIdleCheckModel !== undefined ||
|
||||||
|
config.aiIdleCheckMaxContext !== undefined ||
|
||||||
|
config.aiIdleCheckTimeoutMs !== undefined ||
|
||||||
|
config.aiIdleCheckCooldownMs !== undefined
|
||||||
|
) {
|
||||||
this.aiChecker.updateConfig({
|
this.aiChecker.updateConfig({
|
||||||
enabled: this.config.aiIdleCheckEnabled,
|
enabled: this.config.aiIdleCheckEnabled,
|
||||||
model: this.config.aiIdleCheckModel,
|
model: this.config.aiIdleCheckModel,
|
||||||
@@ -3065,9 +3132,13 @@ export class RespawnController extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sync plan checker config if relevant fields changed
|
// Sync plan checker config if relevant fields changed
|
||||||
if (config.aiPlanCheckEnabled !== undefined || config.aiPlanCheckModel !== undefined ||
|
if (
|
||||||
config.aiPlanCheckMaxContext !== undefined || config.aiPlanCheckTimeoutMs !== undefined ||
|
config.aiPlanCheckEnabled !== undefined ||
|
||||||
config.aiPlanCheckCooldownMs !== undefined) {
|
config.aiPlanCheckModel !== undefined ||
|
||||||
|
config.aiPlanCheckMaxContext !== undefined ||
|
||||||
|
config.aiPlanCheckTimeoutMs !== undefined ||
|
||||||
|
config.aiPlanCheckCooldownMs !== undefined
|
||||||
|
) {
|
||||||
this.planChecker.updateConfig({
|
this.planChecker.updateConfig({
|
||||||
enabled: this.config.aiPlanCheckEnabled,
|
enabled: this.config.aiPlanCheckEnabled,
|
||||||
model: this.config.aiPlanCheckModel,
|
model: this.config.aiPlanCheckModel,
|
||||||
@@ -3276,7 +3347,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const metrics: RespawnCycleMetrics = {
|
const metrics: RespawnCycleMetrics = {
|
||||||
...this.currentCycleMetrics as RespawnCycleMetrics,
|
...(this.currentCycleMetrics as RespawnCycleMetrics),
|
||||||
completedAt: now,
|
completedAt: now,
|
||||||
durationMs: now - (this.currentCycleMetrics.startedAt ?? now),
|
durationMs: now - (this.currentCycleMetrics.startedAt ?? now),
|
||||||
outcome,
|
outcome,
|
||||||
@@ -3299,7 +3370,9 @@ export class RespawnController extends EventEmitter {
|
|||||||
// Clear current cycle
|
// Clear current cycle
|
||||||
this.currentCycleMetrics = null;
|
this.currentCycleMetrics = null;
|
||||||
|
|
||||||
this.log(`Cycle #${metrics.cycleNumber} metrics: ${outcome}, duration=${metrics.durationMs}ms, idle_detection=${metrics.idleDetectionMs}ms`);
|
this.log(
|
||||||
|
`Cycle #${metrics.cycleNumber} metrics: ${outcome}, duration=${metrics.durationMs}ms, idle_detection=${metrics.idleDetectionMs}ms`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3333,8 +3406,8 @@ export class RespawnController extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Recalculate averages using all recent metrics
|
// Recalculate averages using all recent metrics
|
||||||
const durations = this.recentCycleMetrics.map(m => m.durationMs);
|
const durations = this.recentCycleMetrics.map((m) => m.durationMs);
|
||||||
const idleTimes = this.recentCycleMetrics.map(m => m.idleDetectionMs);
|
const idleTimes = this.recentCycleMetrics.map((m) => m.idleDetectionMs);
|
||||||
|
|
||||||
if (durations.length > 0) {
|
if (durations.length > 0) {
|
||||||
agg.avgCycleDurationMs = Math.round(durations.reduce((a, b) => a + b, 0) / durations.length);
|
agg.avgCycleDurationMs = Math.round(durations.reduce((a, b) => a + b, 0) / durations.length);
|
||||||
@@ -3347,9 +3420,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate success rate
|
// Calculate success rate
|
||||||
agg.successRate = agg.totalCycles > 0
|
agg.successRate = agg.totalCycles > 0 ? Math.round((agg.successfulCycles / agg.totalCycles) * 100) : 100;
|
||||||
? Math.round((agg.successfulCycles / agg.totalCycles) * 100)
|
|
||||||
: 100;
|
|
||||||
|
|
||||||
agg.lastUpdatedAt = Date.now();
|
agg.lastUpdatedAt = Date.now();
|
||||||
}
|
}
|
||||||
@@ -3392,18 +3463,18 @@ export class RespawnController extends EventEmitter {
|
|||||||
// Weighted average (cycle success is most important)
|
// Weighted average (cycle success is most important)
|
||||||
const weights = {
|
const weights = {
|
||||||
cycleSuccess: 0.35,
|
cycleSuccess: 0.35,
|
||||||
circuitBreaker: 0.20,
|
circuitBreaker: 0.2,
|
||||||
iterationProgress: 0.20,
|
iterationProgress: 0.2,
|
||||||
aiChecker: 0.15,
|
aiChecker: 0.15,
|
||||||
stuckRecovery: 0.10,
|
stuckRecovery: 0.1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const score = Math.round(
|
const score = Math.round(
|
||||||
components.cycleSuccess * weights.cycleSuccess +
|
components.cycleSuccess * weights.cycleSuccess +
|
||||||
components.circuitBreaker * weights.circuitBreaker +
|
components.circuitBreaker * weights.circuitBreaker +
|
||||||
components.iterationProgress * weights.iterationProgress +
|
components.iterationProgress * weights.iterationProgress +
|
||||||
components.aiChecker * weights.aiChecker +
|
components.aiChecker * weights.aiChecker +
|
||||||
components.stuckRecovery * weights.stuckRecovery
|
components.stuckRecovery * weights.stuckRecovery
|
||||||
);
|
);
|
||||||
|
|
||||||
// Determine status
|
// Determine status
|
||||||
@@ -3446,10 +3517,14 @@ export class RespawnController extends EventEmitter {
|
|||||||
|
|
||||||
const cb = tracker.circuitBreakerStatus;
|
const cb = tracker.circuitBreakerStatus;
|
||||||
switch (cb.state) {
|
switch (cb.state) {
|
||||||
case 'CLOSED': return 100;
|
case 'CLOSED':
|
||||||
case 'HALF_OPEN': return 50;
|
return 100;
|
||||||
case 'OPEN': return 0;
|
case 'HALF_OPEN':
|
||||||
default: return 100;
|
return 50;
|
||||||
|
case 'OPEN':
|
||||||
|
return 0;
|
||||||
|
default:
|
||||||
|
return 100;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3527,8 +3602,10 @@ export class RespawnController extends EventEmitter {
|
|||||||
status: HealthStatus,
|
status: HealthStatus,
|
||||||
components: RalphLoopHealthScore['components']
|
components: RalphLoopHealthScore['components']
|
||||||
): string {
|
): string {
|
||||||
const lowest = Object.entries(components).reduce((min, [key, val]) =>
|
const lowest = Object.entries(components).reduce((min, [key, val]) => (val < min.val ? { key, val } : min), {
|
||||||
val < min.val ? { key, val } : min, { key: '', val: 100 });
|
key: '',
|
||||||
|
val: 100,
|
||||||
|
});
|
||||||
|
|
||||||
if (status === 'excellent') {
|
if (status === 'excellent') {
|
||||||
return `Ralph Loop is operating excellently (${score}/100). All systems healthy.`;
|
return `Ralph Loop is operating excellently (${score}/100). All systems healthy.`;
|
||||||
|
|||||||
@@ -139,13 +139,10 @@ export class RunSummaryTracker {
|
|||||||
// Record state transition
|
// Record state transition
|
||||||
if (oldState && oldState !== newState) {
|
if (oldState && oldState !== newState) {
|
||||||
this.stats.stateTransitions++;
|
this.stats.stateTransitions++;
|
||||||
this.addEvent(
|
this.addEvent('respawn_state_change', 'info', `State: ${oldState} → ${newState}`, details, {
|
||||||
'respawn_state_change',
|
from: oldState,
|
||||||
'info',
|
to: newState,
|
||||||
`State: ${oldState} → ${newState}`,
|
});
|
||||||
details,
|
|
||||||
{ from: oldState, to: newState }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update state tracking
|
// Update state tracking
|
||||||
@@ -214,7 +211,11 @@ export class RunSummaryTracker {
|
|||||||
'info',
|
'info',
|
||||||
`Token milestone: ${this.formatTokens(currentMilestone)}`,
|
`Token milestone: ${this.formatTokens(currentMilestone)}`,
|
||||||
`Input: ${this.formatTokens(inputTokens)}, Output: ${this.formatTokens(outputTokens)}`,
|
`Input: ${this.formatTokens(inputTokens)}, Output: ${this.formatTokens(outputTokens)}`,
|
||||||
{ total, input: inputTokens, output: outputTokens }
|
{
|
||||||
|
total,
|
||||||
|
input: inputTokens,
|
||||||
|
output: outputTokens,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,13 +265,9 @@ export class RunSummaryTracker {
|
|||||||
* Record a Ralph completion detection.
|
* Record a Ralph completion detection.
|
||||||
*/
|
*/
|
||||||
recordRalphCompletion(phrase: string): void {
|
recordRalphCompletion(phrase: string): void {
|
||||||
this.addEvent(
|
this.addEvent('ralph_completion', 'success', 'Ralph completion detected', `Phrase: ${phrase}`, {
|
||||||
'ralph_completion',
|
phrase,
|
||||||
'success',
|
});
|
||||||
'Ralph completion detected',
|
|
||||||
`Phrase: ${phrase}`,
|
|
||||||
{ phrase }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -278,9 +275,7 @@ export class RunSummaryTracker {
|
|||||||
*/
|
*/
|
||||||
recordHookEvent(eventType: string, data?: Record<string, unknown>): void {
|
recordHookEvent(eventType: string, data?: Record<string, unknown>): void {
|
||||||
const severity: RunSummaryEventSeverity =
|
const severity: RunSummaryEventSeverity =
|
||||||
eventType === 'stop' ? 'warning' :
|
eventType === 'stop' ? 'warning' : eventType === 'permission_prompt' ? 'info' : 'info';
|
||||||
eventType === 'permission_prompt' ? 'info' :
|
|
||||||
'info';
|
|
||||||
|
|
||||||
this.addEvent(
|
this.addEvent(
|
||||||
'hook_event',
|
'hook_event',
|
||||||
@@ -311,13 +306,10 @@ export class RunSummaryTracker {
|
|||||||
* Record session started.
|
* Record session started.
|
||||||
*/
|
*/
|
||||||
recordSessionStarted(mode: string, workingDir: string): void {
|
recordSessionStarted(mode: string, workingDir: string): void {
|
||||||
this.addEvent(
|
this.addEvent('session_started', 'success', 'Session started', `Mode: ${mode}, Dir: ${workingDir}`, {
|
||||||
'session_started',
|
mode,
|
||||||
'success',
|
workingDir,
|
||||||
'Session started',
|
});
|
||||||
`Mode: ${mode}, Dir: ${workingDir}`,
|
|
||||||
{ mode, workingDir }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export class SessionLifecycleLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lines = raw.trim().split('\n').filter(Boolean);
|
const lines = raw.trim().split('\n').filter(Boolean);
|
||||||
let entries: LifecycleEntry[] = [];
|
const entries: LifecycleEntry[] = [];
|
||||||
|
|
||||||
// Parse in reverse (newest first) for efficiency with limit
|
// Parse in reverse (newest first) for efficiency with limit
|
||||||
for (let i = lines.length - 1; i >= 0 && entries.length < limit; i--) {
|
for (let i = lines.length - 1; i >= 0 && entries.length < limit; i--) {
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export class SessionManager extends EventEmitter {
|
|||||||
// Create a new lock promise that others will wait on
|
// Create a new lock promise that others will wait on
|
||||||
// Define unlock first to ensure it's always in scope before promise assignment
|
// Define unlock first to ensure it's always in scope before promise assignment
|
||||||
let unlock!: () => void;
|
let unlock!: () => void;
|
||||||
const lockPromise = new Promise<void>(resolve => {
|
const lockPromise = new Promise<void>((resolve) => {
|
||||||
unlock = resolve;
|
unlock = resolve;
|
||||||
});
|
});
|
||||||
this._sessionCreationLock = lockPromise;
|
this._sessionCreationLock = lockPromise;
|
||||||
@@ -196,9 +196,7 @@ export class SessionManager extends EventEmitter {
|
|||||||
* Uses Promise.allSettled to ensure all sessions are stopped even if some fail.
|
* Uses Promise.allSettled to ensure all sessions are stopped even if some fail.
|
||||||
*/
|
*/
|
||||||
async stopAllSessions(): Promise<void> {
|
async stopAllSessions(): Promise<void> {
|
||||||
const stopPromises = Array.from(this.sessions.keys()).map((id) =>
|
const stopPromises = Array.from(this.sessions.keys()).map((id) => this.stopSession(id));
|
||||||
this.stopSession(id)
|
|
||||||
);
|
|
||||||
const results = await Promise.allSettled(stopPromises);
|
const results = await Promise.allSettled(stopPromises);
|
||||||
// Log any failures but don't throw - best effort cleanup
|
// Log any failures but don't throw - best effort cleanup
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
@@ -292,4 +290,3 @@ export function getSessionManager(): SessionManager {
|
|||||||
}
|
}
|
||||||
return managerInstance;
|
return managerInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,19 +18,26 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import * as pty from 'node-pty';
|
import * as pty from 'node-pty';
|
||||||
import { SessionState, SessionStatus, SessionConfig, RalphTrackerState, RalphTodoItem, ActiveBashTool, NiceConfig, DEFAULT_NICE_CONFIG, type ClaudeMode, type SessionMode, type OpenCodeConfig } from './types.js';
|
import {
|
||||||
|
SessionState,
|
||||||
|
SessionStatus,
|
||||||
|
SessionConfig,
|
||||||
|
RalphTrackerState,
|
||||||
|
RalphTodoItem,
|
||||||
|
ActiveBashTool,
|
||||||
|
NiceConfig,
|
||||||
|
DEFAULT_NICE_CONFIG,
|
||||||
|
type ClaudeMode,
|
||||||
|
type SessionMode,
|
||||||
|
type OpenCodeConfig,
|
||||||
|
} from './types.js';
|
||||||
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
||||||
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
||||||
import { RalphTracker } from './ralph-tracker.js';
|
import { RalphTracker } from './ralph-tracker.js';
|
||||||
import { BashToolParser } from './bash-tool-parser.js';
|
import { BashToolParser } from './bash-tool-parser.js';
|
||||||
import { BufferAccumulator } from './utils/buffer-accumulator.js';
|
import { BufferAccumulator } from './utils/buffer-accumulator.js';
|
||||||
import { LRUMap } from './utils/lru-map.js';
|
import { LRUMap } from './utils/lru-map.js';
|
||||||
import {
|
import { ANSI_ESCAPE_PATTERN_FULL, TOKEN_PATTERN, SPINNER_PATTERN, MAX_SESSION_TOKENS } from './utils/index.js';
|
||||||
ANSI_ESCAPE_PATTERN_FULL,
|
|
||||||
TOKEN_PATTERN,
|
|
||||||
SPINNER_PATTERN,
|
|
||||||
MAX_SESSION_TOKENS,
|
|
||||||
} from './utils/index.js';
|
|
||||||
import {
|
import {
|
||||||
MAX_TERMINAL_BUFFER_SIZE,
|
MAX_TERMINAL_BUFFER_SIZE,
|
||||||
TRIM_TERMINAL_TO as TERMINAL_BUFFER_TRIM_SIZE,
|
TRIM_TERMINAL_TO as TERMINAL_BUFFER_TRIM_SIZE,
|
||||||
@@ -68,6 +75,7 @@ const GRACEFUL_SHUTDOWN_DELAY_MS = 100;
|
|||||||
|
|
||||||
// Filter out terminal focus escape sequences (focus in/out reports)
|
// Filter out terminal focus escape sequences (focus in/out reports)
|
||||||
// ^[[I (focus in), ^[[O (focus out), and the enable/disable sequences
|
// ^[[I (focus in), ^[[O (focus out), and the enable/disable sequences
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
const FOCUS_ESCAPE_FILTER = /\x1b\[\?1004[hl]|\x1b\[[IO]/g;
|
const FOCUS_ESCAPE_FILTER = /\x1b\[\?1004[hl]|\x1b\[[IO]/g;
|
||||||
|
|
||||||
// Pattern to match Task tool invocations in terminal output
|
// Pattern to match Task tool invocations in terminal output
|
||||||
@@ -78,8 +86,10 @@ const TASK_TOOL_PATTERN = /\b(Explore|Task|Bash|Plan|general-purpose)\(([^)]+)\)
|
|||||||
|
|
||||||
// Pre-compiled patterns for hot paths (avoid regex compilation per call)
|
// Pre-compiled patterns for hot paths (avoid regex compilation per call)
|
||||||
/** Pattern to strip leading ANSI escapes and whitespace from terminal buffer */
|
/** Pattern to strip leading ANSI escapes and whitespace from terminal buffer */
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
const LEADING_ANSI_WHITESPACE_PATTERN = /^(\x1b\[\??[\d;]*[A-Za-z]|[\s\r\n])+/;
|
const LEADING_ANSI_WHITESPACE_PATTERN = /^(\x1b\[\??[\d;]*[A-Za-z]|[\s\r\n])+/;
|
||||||
/** Pattern to match Ctrl+L (form feed) characters */
|
/** Pattern to match Ctrl+L (form feed) characters */
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
const CTRL_L_PATTERN = /\x0c/g;
|
const CTRL_L_PATTERN = /\x0c/g;
|
||||||
/** Pattern to split by newlines (CR or LF) */
|
/** Pattern to split by newlines (CR or LF) */
|
||||||
const NEWLINE_SPLIT_PATTERN = /\r?\n/;
|
const NEWLINE_SPLIT_PATTERN = /\r?\n/;
|
||||||
@@ -96,11 +106,7 @@ import { getAugmentedPath } from './utils/claude-cli-resolver.js';
|
|||||||
* @param operation - Description of the operation for error messages
|
* @param operation - Description of the operation for error messages
|
||||||
* @returns Promise that resolves/rejects with the original result or timeout error
|
* @returns Promise that resolves/rejects with the original result or timeout error
|
||||||
*/
|
*/
|
||||||
function withTimeout<T>(
|
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, operation: string): Promise<T> {
|
||||||
promise: Promise<T>,
|
|
||||||
timeoutMs: number,
|
|
||||||
operation: string
|
|
||||||
): Promise<T> {
|
|
||||||
let timeoutId: NodeJS.Timeout;
|
let timeoutId: NodeJS.Timeout;
|
||||||
|
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
@@ -193,7 +199,12 @@ export interface SessionEvents {
|
|||||||
/** Active Bash tools list updated */
|
/** Active Bash tools list updated */
|
||||||
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
|
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
|
||||||
/** CLI info (version, model, account) updated */
|
/** CLI info (version, model, account) updated */
|
||||||
cliInfoUpdated: (info: { version: string | null; model: string | null; accountType: string | null; latestVersion: string | null }) => void;
|
cliInfoUpdated: (info: {
|
||||||
|
version: string | null;
|
||||||
|
model: string | null;
|
||||||
|
accountType: string | null;
|
||||||
|
latestVersion: string | null;
|
||||||
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionMode is imported from types.ts (single source of truth)
|
// SessionMode is imported from types.ts (single source of truth)
|
||||||
@@ -258,7 +269,7 @@ export class Session extends EventEmitter {
|
|||||||
private _lineBufferFlushTimer: NodeJS.Timeout | null = null;
|
private _lineBufferFlushTimer: NodeJS.Timeout | null = null;
|
||||||
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
|
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
|
||||||
private rejectPromise: ((reason: Error) => void) | null = null;
|
private rejectPromise: ((reason: Error) => void) | null = null;
|
||||||
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
|
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
|
||||||
private _isWorking: boolean = false;
|
private _isWorking: boolean = false;
|
||||||
private _lastPromptTime: number = 0;
|
private _lastPromptTime: number = 0;
|
||||||
private activityTimeout: NodeJS.Timeout | null = null;
|
private activityTimeout: NodeJS.Timeout | null = null;
|
||||||
@@ -356,7 +367,9 @@ export class Session extends EventEmitter {
|
|||||||
// Task descriptions parsed from terminal output (e.g., "Explore(Description)")
|
// Task descriptions parsed from terminal output (e.g., "Explore(Description)")
|
||||||
// Used to correlate with SubagentWatcher discoveries for better window titles
|
// Used to correlate with SubagentWatcher discoveries for better window titles
|
||||||
// Uses LRUMap for automatic eviction at MAX_TASK_DESCRIPTIONS limit
|
// Uses LRUMap for automatic eviction at MAX_TASK_DESCRIPTIONS limit
|
||||||
private _recentTaskDescriptions: LRUMap<number, string> = new LRUMap({ maxSize: Session.MAX_TASK_DESCRIPTIONS });
|
private _recentTaskDescriptions: LRUMap<number, string> = new LRUMap({
|
||||||
|
maxSize: Session.MAX_TASK_DESCRIPTIONS,
|
||||||
|
});
|
||||||
|
|
||||||
// Throttle expensive PTY processing (Ralph, bash parser, task descriptions)
|
// Throttle expensive PTY processing (Ralph, bash parser, task descriptions)
|
||||||
// Accumulates clean data between processing windows to avoid running regex on every chunk
|
// Accumulates clean data between processing windows to avoid running regex on every chunk
|
||||||
@@ -365,26 +378,28 @@ export class Session extends EventEmitter {
|
|||||||
private _expensiveProcessTimer: NodeJS.Timeout | null = null;
|
private _expensiveProcessTimer: NodeJS.Timeout | null = null;
|
||||||
private static readonly EXPENSIVE_PROCESS_INTERVAL_MS = 150; // Process at most every 150ms
|
private static readonly EXPENSIVE_PROCESS_INTERVAL_MS = 150; // Process at most every 150ms
|
||||||
|
|
||||||
constructor(config: Partial<SessionConfig> & {
|
constructor(
|
||||||
workingDir: string;
|
config: Partial<SessionConfig> & {
|
||||||
mode?: SessionMode;
|
workingDir: string;
|
||||||
name?: string;
|
mode?: SessionMode;
|
||||||
/** Terminal multiplexer instance (tmux) */
|
name?: string;
|
||||||
mux?: TerminalMultiplexer;
|
/** Terminal multiplexer instance (tmux) */
|
||||||
/** Whether to use multiplexer wrapping */
|
mux?: TerminalMultiplexer;
|
||||||
useMux?: boolean;
|
/** Whether to use multiplexer wrapping */
|
||||||
/** Existing mux session for restored sessions */
|
useMux?: boolean;
|
||||||
muxSession?: MuxSession;
|
/** Existing mux session for restored sessions */
|
||||||
niceConfig?: NiceConfig; // Nice prioritying configuration
|
muxSession?: MuxSession;
|
||||||
/** Claude model override (e.g., 'opus', 'sonnet', 'haiku') */
|
niceConfig?: NiceConfig; // Nice prioritying configuration
|
||||||
model?: string;
|
/** Claude model override (e.g., 'opus', 'sonnet', 'haiku') */
|
||||||
/** Claude CLI startup permission mode */
|
model?: string;
|
||||||
claudeMode?: ClaudeMode;
|
/** Claude CLI startup permission mode */
|
||||||
/** Comma-separated allowed tools (for 'allowedTools' mode) */
|
claudeMode?: ClaudeMode;
|
||||||
allowedTools?: string;
|
/** Comma-separated allowed tools (for 'allowedTools' mode) */
|
||||||
/** OpenCode configuration (only for mode === 'opencode') */
|
allowedTools?: string;
|
||||||
openCodeConfig?: OpenCodeConfig;
|
/** OpenCode configuration (only for mode === 'opencode') */
|
||||||
}) {
|
openCodeConfig?: OpenCodeConfig;
|
||||||
|
}
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
this.setMaxListeners(25);
|
this.setMaxListeners(25);
|
||||||
|
|
||||||
@@ -472,7 +487,6 @@ export class Session extends EventEmitter {
|
|||||||
this._bashToolParser.on('toolStart', this._bashToolHandlers.toolStart);
|
this._bashToolParser.on('toolStart', this._bashToolHandlers.toolStart);
|
||||||
this._bashToolParser.on('toolEnd', this._bashToolHandlers.toolEnd);
|
this._bashToolParser.on('toolEnd', this._bashToolHandlers.toolEnd);
|
||||||
this._bashToolParser.on('toolsUpdate', this._bashToolHandlers.toolsUpdate);
|
this._bashToolParser.on('toolsUpdate', this._bashToolHandlers.toolsUpdate);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get status(): SessionStatus {
|
get status(): SessionStatus {
|
||||||
@@ -673,17 +687,23 @@ export class Session extends EventEmitter {
|
|||||||
restoreTokens(inputTokens: number, outputTokens: number, totalCost: number): void {
|
restoreTokens(inputTokens: number, outputTokens: number, totalCost: number): void {
|
||||||
// Sanity check: reject absurdly large individual values
|
// Sanity check: reject absurdly large individual values
|
||||||
if (inputTokens > MAX_SESSION_TOKENS || outputTokens > MAX_SESSION_TOKENS) {
|
if (inputTokens > MAX_SESSION_TOKENS || outputTokens > MAX_SESSION_TOKENS) {
|
||||||
console.warn(`[Session ${this.id}] Rejected absurd restored tokens: input=${inputTokens}, output=${outputTokens}`);
|
console.warn(
|
||||||
|
`[Session ${this.id}] Rejected absurd restored tokens: input=${inputTokens}, output=${outputTokens}`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Check token sum doesn't overflow MAX_SESSION_TOKENS
|
// Check token sum doesn't overflow MAX_SESSION_TOKENS
|
||||||
if (inputTokens + outputTokens > MAX_SESSION_TOKENS) {
|
if (inputTokens + outputTokens > MAX_SESSION_TOKENS) {
|
||||||
console.warn(`[Session ${this.id}] Rejected token sum overflow: input=${inputTokens} + output=${outputTokens} = ${inputTokens + outputTokens} > ${MAX_SESSION_TOKENS}`);
|
console.warn(
|
||||||
|
`[Session ${this.id}] Rejected token sum overflow: input=${inputTokens} + output=${outputTokens} = ${inputTokens + outputTokens} > ${MAX_SESSION_TOKENS}`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Reject negative values
|
// Reject negative values
|
||||||
if (inputTokens < 0 || outputTokens < 0 || totalCost < 0) {
|
if (inputTokens < 0 || outputTokens < 0 || totalCost < 0) {
|
||||||
console.warn(`[Session ${this.id}] Rejected negative restored tokens: input=${inputTokens}, output=${outputTokens}, cost=${totalCost}`);
|
console.warn(
|
||||||
|
`[Session ${this.id}] Rejected negative restored tokens: input=${inputTokens}, output=${outputTokens}, cost=${totalCost}`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -722,7 +742,9 @@ export class Session extends EventEmitter {
|
|||||||
if (threshold !== undefined) {
|
if (threshold !== undefined) {
|
||||||
// Validate threshold bounds
|
// Validate threshold bounds
|
||||||
if (threshold < Session.MIN_AUTO_THRESHOLD || threshold > Session.MAX_AUTO_THRESHOLD) {
|
if (threshold < Session.MIN_AUTO_THRESHOLD || threshold > Session.MAX_AUTO_THRESHOLD) {
|
||||||
console.warn(`[Session ${this.id}] Invalid autoClear threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_CLEAR_THRESHOLD}.`);
|
console.warn(
|
||||||
|
`[Session ${this.id}] Invalid autoClear threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_CLEAR_THRESHOLD}.`
|
||||||
|
);
|
||||||
this._autoClearThreshold = Session.DEFAULT_AUTO_CLEAR_THRESHOLD;
|
this._autoClearThreshold = Session.DEFAULT_AUTO_CLEAR_THRESHOLD;
|
||||||
} else {
|
} else {
|
||||||
this._autoClearThreshold = threshold;
|
this._autoClearThreshold = threshold;
|
||||||
@@ -747,7 +769,9 @@ export class Session extends EventEmitter {
|
|||||||
if (threshold !== undefined) {
|
if (threshold !== undefined) {
|
||||||
// Validate threshold bounds
|
// Validate threshold bounds
|
||||||
if (threshold < Session.MIN_AUTO_THRESHOLD || threshold > Session.MAX_AUTO_THRESHOLD) {
|
if (threshold < Session.MIN_AUTO_THRESHOLD || threshold > Session.MAX_AUTO_THRESHOLD) {
|
||||||
console.warn(`[Session ${this.id}] Invalid autoCompact threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_COMPACT_THRESHOLD}.`);
|
console.warn(
|
||||||
|
`[Session ${this.id}] Invalid autoCompact threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_COMPACT_THRESHOLD}.`
|
||||||
|
);
|
||||||
this._autoCompactThreshold = Session.DEFAULT_AUTO_COMPACT_THRESHOLD;
|
this._autoCompactThreshold = Session.DEFAULT_AUTO_COMPACT_THRESHOLD;
|
||||||
} else {
|
} else {
|
||||||
this._autoCompactThreshold = threshold;
|
this._autoCompactThreshold = threshold;
|
||||||
@@ -911,7 +935,9 @@ export class Session extends EventEmitter {
|
|||||||
this._lastActivityAt = Date.now();
|
this._lastActivityAt = Date.now();
|
||||||
|
|
||||||
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : 'Claude';
|
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : 'Claude';
|
||||||
console.log(`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : ''));
|
console.log(
|
||||||
|
`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '')
|
||||||
|
);
|
||||||
|
|
||||||
// If mux wrapping is enabled, create or attach to a mux session
|
// If mux wrapping is enabled, create or attach to a mux session
|
||||||
if (this._useMux && this._mux) {
|
if (this._useMux && this._mux) {
|
||||||
@@ -928,16 +954,21 @@ export class Session extends EventEmitter {
|
|||||||
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
|
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
|
||||||
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
||||||
const newPid = await this._mux.respawnPane({
|
const newPid = await this._mux.respawnPane({
|
||||||
sessionId: this.id, workingDir: this.workingDir, mode: this.mode,
|
sessionId: this.id,
|
||||||
niceConfig: this._niceConfig, model: this._model, claudeMode: this._claudeMode,
|
workingDir: this.workingDir,
|
||||||
allowedTools: this._allowedTools, openCodeConfig: this._openCodeConfig,
|
mode: this.mode,
|
||||||
|
niceConfig: this._niceConfig,
|
||||||
|
model: this._model,
|
||||||
|
claudeMode: this._claudeMode,
|
||||||
|
allowedTools: this._allowedTools,
|
||||||
|
openCodeConfig: this._openCodeConfig,
|
||||||
});
|
});
|
||||||
if (!newPid) {
|
if (!newPid) {
|
||||||
console.error('[Session] Failed to respawn pane, will create new session');
|
console.error('[Session] Failed to respawn pane, will create new session');
|
||||||
needsNewSession = true;
|
needsNewSession = true;
|
||||||
} else {
|
} else {
|
||||||
// Wait a moment for the respawned process to fully start
|
// Wait a moment for the respawned process to fully start
|
||||||
await new Promise(resolve => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -948,9 +979,14 @@ export class Session extends EventEmitter {
|
|||||||
} else {
|
} else {
|
||||||
// Create a new mux session
|
// Create a new mux session
|
||||||
this._muxSession = await this._mux.createSession({
|
this._muxSession = await this._mux.createSession({
|
||||||
sessionId: this.id, workingDir: this.workingDir, mode: this.mode,
|
sessionId: this.id,
|
||||||
name: this._name, niceConfig: this._niceConfig, model: this._model,
|
workingDir: this.workingDir,
|
||||||
claudeMode: this._claudeMode, allowedTools: this._allowedTools,
|
mode: this.mode,
|
||||||
|
name: this._name,
|
||||||
|
niceConfig: this._niceConfig,
|
||||||
|
model: this._model,
|
||||||
|
claudeMode: this._claudeMode,
|
||||||
|
allowedTools: this._allowedTools,
|
||||||
openCodeConfig: this._openCodeConfig,
|
openCodeConfig: this._openCodeConfig,
|
||||||
});
|
});
|
||||||
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
||||||
@@ -962,13 +998,21 @@ export class Session extends EventEmitter {
|
|||||||
this.ptyProcess = pty.spawn(
|
this.ptyProcess = pty.spawn(
|
||||||
this._mux.getAttachCommand(),
|
this._mux.getAttachCommand(),
|
||||||
this._mux.getAttachArgs(this._muxSession!.muxName),
|
this._mux.getAttachArgs(this._muxSession!.muxName),
|
||||||
{
|
{
|
||||||
name: 'xterm-256color',
|
name: 'xterm-256color',
|
||||||
cols: 120,
|
cols: 120,
|
||||||
rows: 40,
|
rows: 40,
|
||||||
cwd: this.workingDir,
|
cwd: this.workingDir,
|
||||||
env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', TERM: 'xterm-256color', COLORTERM: undefined, CLAUDECODE: undefined },
|
env: {
|
||||||
});
|
...process.env,
|
||||||
|
LANG: 'en_US.UTF-8',
|
||||||
|
LC_ALL: 'en_US.UTF-8',
|
||||||
|
TERM: 'xterm-256color',
|
||||||
|
COLORTERM: undefined,
|
||||||
|
CLAUDECODE: undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Set claudeSessionId immediately since we passed --session-id to Claude
|
// Set claudeSessionId immediately since we passed --session-id to Claude
|
||||||
// The mux manager passes --session-id ${sessionId} to Claude
|
// The mux manager passes --session-id ${sessionId} to Claude
|
||||||
@@ -1010,9 +1054,7 @@ export class Session extends EventEmitter {
|
|||||||
// Clean the buffer - remove mux init junk before actual content
|
// Clean the buffer - remove mux init junk before actual content
|
||||||
// Strip: cursor movement (\x1b[nA/B/C/D), positioning (\x1b[n;nH),
|
// Strip: cursor movement (\x1b[nA/B/C/D), positioning (\x1b[n;nH),
|
||||||
// clear screen (\x1b[2J), scroll region (\x1b[n;nr), and whitespace
|
// clear screen (\x1b[2J), scroll region (\x1b[n;nr), and whitespace
|
||||||
this._terminalBuffer.set(
|
this._terminalBuffer.set(bufferValue.replace(LEADING_ANSI_WHITESPACE_PATTERN, ''));
|
||||||
bufferValue.replace(LEADING_ANSI_WHITESPACE_PATTERN, '')
|
|
||||||
);
|
|
||||||
// Signal client to refresh
|
// Signal client to refresh
|
||||||
this.emit('clearTerminal');
|
this.emit('clearTerminal');
|
||||||
}
|
}
|
||||||
@@ -1081,9 +1123,7 @@ export class Session extends EventEmitter {
|
|||||||
|
|
||||||
this.ptyProcess.onData((rawData: string) => {
|
this.ptyProcess.onData((rawData: string) => {
|
||||||
// Filter out focus escape sequences and Ctrl+L (form feed)
|
// Filter out focus escape sequences and Ctrl+L (form feed)
|
||||||
const data = rawData
|
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '').replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L
|
||||||
.replace(FOCUS_ESCAPE_FILTER, '')
|
|
||||||
.replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L
|
|
||||||
if (!data) return; // Skip if only filtered sequences
|
if (!data) return; // Skip if only filtered sequences
|
||||||
|
|
||||||
// BufferAccumulator handles auto-trimming when max size exceeded
|
// BufferAccumulator handles auto-trimming when max size exceeded
|
||||||
@@ -1252,8 +1292,12 @@ export class Session extends EventEmitter {
|
|||||||
// Only check if spinner didn't already trigger working state
|
// Only check if spinner didn't already trigger working state
|
||||||
if (!this._isWorking) {
|
if (!this._isWorking) {
|
||||||
const cleanData = getCleanData();
|
const cleanData = getCleanData();
|
||||||
if (cleanData.includes('Thinking') || cleanData.includes('Writing') ||
|
if (
|
||||||
cleanData.includes('Reading') || cleanData.includes('Running')) {
|
cleanData.includes('Thinking') ||
|
||||||
|
cleanData.includes('Writing') ||
|
||||||
|
cleanData.includes('Reading') ||
|
||||||
|
cleanData.includes('Running')
|
||||||
|
) {
|
||||||
this._isWorking = true;
|
this._isWorking = true;
|
||||||
this._status = 'busy';
|
this._status = 'busy';
|
||||||
this.emit('working');
|
this.emit('working');
|
||||||
@@ -1293,7 +1337,10 @@ export class Session extends EventEmitter {
|
|||||||
|
|
||||||
// Use user's default shell or bash
|
// Use user's default shell or bash
|
||||||
const shell = process.env.SHELL || '/bin/bash';
|
const shell = process.env.SHELL || '/bin/bash';
|
||||||
console.log('[Session] Starting shell session with:', shell + (this._useMux ? ` (with ${this._mux!.backend})` : ''));
|
console.log(
|
||||||
|
'[Session] Starting shell session with:',
|
||||||
|
shell + (this._useMux ? ` (with ${this._mux!.backend})` : '')
|
||||||
|
);
|
||||||
|
|
||||||
// If mux wrapping is enabled, create or attach to a mux session
|
// If mux wrapping is enabled, create or attach to a mux session
|
||||||
if (this._useMux && this._mux) {
|
if (this._useMux && this._mux) {
|
||||||
@@ -1308,12 +1355,17 @@ export class Session extends EventEmitter {
|
|||||||
let needsNewSession = false;
|
let needsNewSession = false;
|
||||||
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
|
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
|
||||||
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
||||||
const newPid = await this._mux.respawnPane({ sessionId: this.id, workingDir: this.workingDir, mode: 'shell', niceConfig: this._niceConfig });
|
const newPid = await this._mux.respawnPane({
|
||||||
|
sessionId: this.id,
|
||||||
|
workingDir: this.workingDir,
|
||||||
|
mode: 'shell',
|
||||||
|
niceConfig: this._niceConfig,
|
||||||
|
});
|
||||||
if (!newPid) {
|
if (!newPid) {
|
||||||
console.error('[Session] Failed to respawn pane, will create new session');
|
console.error('[Session] Failed to respawn pane, will create new session');
|
||||||
needsNewSession = true;
|
needsNewSession = true;
|
||||||
} else {
|
} else {
|
||||||
await new Promise(resolve => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1323,7 +1375,13 @@ export class Session extends EventEmitter {
|
|||||||
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
||||||
} else {
|
} else {
|
||||||
// Create a new mux session
|
// Create a new mux session
|
||||||
this._muxSession = await this._mux.createSession({ sessionId: this.id, workingDir: this.workingDir, mode: 'shell', name: this._name, niceConfig: this._niceConfig });
|
this._muxSession = await this._mux.createSession({
|
||||||
|
sessionId: this.id,
|
||||||
|
workingDir: this.workingDir,
|
||||||
|
mode: 'shell',
|
||||||
|
name: this._name,
|
||||||
|
niceConfig: this._niceConfig,
|
||||||
|
});
|
||||||
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
||||||
// No extra sleep — createSession() already waits for tmux readiness
|
// No extra sleep — createSession() already waits for tmux readiness
|
||||||
}
|
}
|
||||||
@@ -1333,13 +1391,21 @@ export class Session extends EventEmitter {
|
|||||||
this.ptyProcess = pty.spawn(
|
this.ptyProcess = pty.spawn(
|
||||||
this._mux.getAttachCommand(),
|
this._mux.getAttachCommand(),
|
||||||
this._mux.getAttachArgs(this._muxSession!.muxName),
|
this._mux.getAttachArgs(this._muxSession!.muxName),
|
||||||
{
|
{
|
||||||
name: 'xterm-256color',
|
name: 'xterm-256color',
|
||||||
cols: 120,
|
cols: 120,
|
||||||
rows: 40,
|
rows: 40,
|
||||||
cwd: this.workingDir,
|
cwd: this.workingDir,
|
||||||
env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', TERM: 'xterm-256color', COLORTERM: undefined, CLAUDECODE: undefined },
|
env: {
|
||||||
});
|
...process.env,
|
||||||
|
LANG: 'en_US.UTF-8',
|
||||||
|
LC_ALL: 'en_US.UTF-8',
|
||||||
|
TERM: 'xterm-256color',
|
||||||
|
COLORTERM: undefined,
|
||||||
|
CLAUDECODE: undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
} catch (spawnErr) {
|
} catch (spawnErr) {
|
||||||
console.error('[Session] Failed to spawn PTY for shell mux attachment:', spawnErr);
|
console.error('[Session] Failed to spawn PTY for shell mux attachment:', spawnErr);
|
||||||
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
|
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
|
||||||
@@ -1474,7 +1540,7 @@ export class Session extends EventEmitter {
|
|||||||
this._messages = [];
|
this._messages = [];
|
||||||
this._lineBuffer = '';
|
this._lineBuffer = '';
|
||||||
this._lastActivityAt = Date.now();
|
this._lastActivityAt = Date.now();
|
||||||
this._promptResolved = false; // Reset race condition guard
|
this._promptResolved = false; // Reset race condition guard
|
||||||
|
|
||||||
this.resolvePromise = resolve;
|
this.resolvePromise = resolve;
|
||||||
this.rejectPromise = reject;
|
this.rejectPromise = reject;
|
||||||
@@ -1482,14 +1548,13 @@ export class Session extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
// Spawn claude in a real PTY
|
// Spawn claude in a real PTY
|
||||||
const model = options?.model;
|
const model = options?.model;
|
||||||
console.log('[Session] Spawning PTY for claude with prompt:', prompt.substring(0, 50), model ? `(model: ${model})` : '');
|
console.log(
|
||||||
|
'[Session] Spawning PTY for claude with prompt:',
|
||||||
|
prompt.substring(0, 50),
|
||||||
|
model ? `(model: ${model})` : ''
|
||||||
|
);
|
||||||
|
|
||||||
const args = [
|
const args = ['-p', '--verbose', '--dangerously-skip-permissions', '--output-format', 'stream-json'];
|
||||||
'-p',
|
|
||||||
'--verbose',
|
|
||||||
'--dangerously-skip-permissions',
|
|
||||||
'--output-format', 'stream-json',
|
|
||||||
];
|
|
||||||
if (model) {
|
if (model) {
|
||||||
args.push('--model', model);
|
args.push('--model', model);
|
||||||
}
|
}
|
||||||
@@ -1517,7 +1582,10 @@ export class Session extends EventEmitter {
|
|||||||
});
|
});
|
||||||
} catch (spawnErr) {
|
} catch (spawnErr) {
|
||||||
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
|
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
|
||||||
this.emit('error', `Failed to spawn Claude: ${spawnErr instanceof Error ? spawnErr.message : String(spawnErr)}`);
|
this.emit(
|
||||||
|
'error',
|
||||||
|
`Failed to spawn Claude: ${spawnErr instanceof Error ? spawnErr.message : String(spawnErr)}`
|
||||||
|
);
|
||||||
throw spawnErr;
|
throw spawnErr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1561,7 +1629,7 @@ export class Session extends EventEmitter {
|
|||||||
this.rejectPromise = null;
|
this.rejectPromise = null;
|
||||||
|
|
||||||
// Find result from parsed messages or use text output
|
// Find result from parsed messages or use text output
|
||||||
const resultMsg = this._messages.find(m => m.type === 'result');
|
const resultMsg = this._messages.find((m) => m.type === 'result');
|
||||||
|
|
||||||
if (resultMsg && !resultMsg.is_error) {
|
if (resultMsg && !resultMsg.is_error) {
|
||||||
this._status = 'idle';
|
this._status = 'idle';
|
||||||
@@ -1579,13 +1647,15 @@ export class Session extends EventEmitter {
|
|||||||
} else {
|
} else {
|
||||||
this._status = 'idle';
|
this._status = 'idle';
|
||||||
if (resolve) {
|
if (resolve) {
|
||||||
resolve({ result: this._textOutput.value || this._terminalBuffer.value, cost: this._totalCost });
|
resolve({
|
||||||
|
result: this._textOutput.value || this._terminalBuffer.value,
|
||||||
|
cost: this._totalCost,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('exit', exitCode);
|
this.emit('exit', exitCode);
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._status = 'error';
|
this._status = 'error';
|
||||||
reject(err);
|
reject(err);
|
||||||
@@ -1649,8 +1719,8 @@ export class Session extends EventEmitter {
|
|||||||
|
|
||||||
// Extract Claude session ID from messages (can be in any message type)
|
// Extract Claude session ID from messages (can be in any message type)
|
||||||
// Support both sessionId (camelCase) and session_id (snake_case)
|
// Support both sessionId (camelCase) and session_id (snake_case)
|
||||||
const msgSessionId = (msg as unknown as Record<string, unknown>).sessionId as string | undefined
|
const msgSessionId =
|
||||||
?? msg.session_id;
|
((msg as unknown as Record<string, unknown>).sessionId as string | undefined) ?? msg.session_id;
|
||||||
if (msgSessionId && !this._claudeSessionId) {
|
if (msgSessionId && !this._claudeSessionId) {
|
||||||
this._claudeSessionId = msgSessionId;
|
this._claudeSessionId = msgSessionId;
|
||||||
}
|
}
|
||||||
@@ -1689,7 +1759,10 @@ export class Session extends EventEmitter {
|
|||||||
}
|
}
|
||||||
} catch (parseErr) {
|
} catch (parseErr) {
|
||||||
// Not JSON, just regular output - this is expected for non-JSON lines
|
// Not JSON, just regular output - this is expected for non-JSON lines
|
||||||
console.debug('[Session] Line not JSON (expected for text output):', parseErr instanceof Error ? parseErr.message : parseErr);
|
console.debug(
|
||||||
|
'[Session] Line not JSON (expected for text output):',
|
||||||
|
parseErr instanceof Error ? parseErr.message : parseErr
|
||||||
|
);
|
||||||
this._textOutput.append(line + '\n');
|
this._textOutput.append(line + '\n');
|
||||||
}
|
}
|
||||||
} else if (trimmed) {
|
} else if (trimmed) {
|
||||||
@@ -1829,7 +1902,9 @@ export class Session extends EventEmitter {
|
|||||||
// Safety: Reject M values that would result in > 500k tokens
|
// Safety: Reject M values that would result in > 500k tokens
|
||||||
// Claude's context window is ~200k, so anything claiming millions is likely a false match
|
// Claude's context window is ~200k, so anything claiming millions is likely a false match
|
||||||
if (tokenCount > 0.5) {
|
if (tokenCount > 0.5) {
|
||||||
console.warn(`[Session ${this.id}] Rejected suspicious M token value: ${tokenMatch[0]} (would be ${tokenCount * 1000000} tokens)`);
|
console.warn(
|
||||||
|
`[Session ${this.id}] Rejected suspicious M token value: ${tokenMatch[0]} (would be ${tokenCount * 1000000} tokens)`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tokenCount *= 1000000;
|
tokenCount *= 1000000;
|
||||||
@@ -1850,7 +1925,9 @@ export class Session extends EventEmitter {
|
|||||||
// Safety: Reject suspiciously large jumps (max 100k per update)
|
// Safety: Reject suspiciously large jumps (max 100k per update)
|
||||||
const MAX_DELTA_PER_UPDATE = 100_000;
|
const MAX_DELTA_PER_UPDATE = 100_000;
|
||||||
if (delta > MAX_DELTA_PER_UPDATE) {
|
if (delta > MAX_DELTA_PER_UPDATE) {
|
||||||
console.warn(`[Session ${this.id}] Rejected suspicious token jump: ${currentTotal} -> ${tokenCount} (delta: ${delta})`);
|
console.warn(
|
||||||
|
`[Session ${this.id}] Rejected suspicious token jump: ${currentTotal} -> ${tokenCount} (delta: ${delta})`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1874,7 +1951,12 @@ export class Session extends EventEmitter {
|
|||||||
if (this._cliInfoParsed) return;
|
if (this._cliInfoParsed) return;
|
||||||
|
|
||||||
// Quick pre-checks
|
// Quick pre-checks
|
||||||
if (!cleanData.includes('Claude') && !cleanData.includes('current:') && !cleanData.includes('Opus') && !cleanData.includes('Sonnet')) {
|
if (
|
||||||
|
!cleanData.includes('Claude') &&
|
||||||
|
!cleanData.includes('current:') &&
|
||||||
|
!cleanData.includes('Opus') &&
|
||||||
|
!cleanData.includes('Sonnet')
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let changed = false;
|
let changed = false;
|
||||||
@@ -1960,14 +2042,12 @@ export class Session extends EventEmitter {
|
|||||||
if (this._isStopped) return;
|
if (this._isStopped) return;
|
||||||
|
|
||||||
// Send /compact command with optional prompt
|
// Send /compact command with optional prompt
|
||||||
const compactCmd = this._autoCompactPrompt
|
const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r';
|
||||||
? `/compact ${this._autoCompactPrompt}\r`
|
|
||||||
: '/compact\r';
|
|
||||||
await this.writeViaMux(compactCmd);
|
await this.writeViaMux(compactCmd);
|
||||||
this.emit('autoCompact', {
|
this.emit('autoCompact', {
|
||||||
tokens: totalTokens,
|
tokens: totalTokens,
|
||||||
threshold: this._autoCompactThreshold,
|
threshold: this._autoCompactThreshold,
|
||||||
prompt: this._autoCompactPrompt || undefined
|
prompt: this._autoCompactPrompt || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait a moment then re-enable (longer than clear since compact takes time)
|
// Wait a moment then re-enable (longer than clear since compact takes time)
|
||||||
@@ -2121,7 +2201,7 @@ export class Session extends EventEmitter {
|
|||||||
async sendInput(input: string): Promise<void> {
|
async sendInput(input: string): Promise<void> {
|
||||||
this._status = 'busy';
|
this._status = 'busy';
|
||||||
this._lastActivityAt = Date.now();
|
this._lastActivityAt = Date.now();
|
||||||
this.runPrompt(input).catch(err => {
|
this.runPrompt(input).catch((err) => {
|
||||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
this.emit('error', errorMsg);
|
this.emit('error', errorMsg);
|
||||||
});
|
});
|
||||||
@@ -2260,7 +2340,7 @@ export class Session extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Give it a moment to terminate gracefully
|
// Give it a moment to terminate gracefully
|
||||||
await new Promise(resolve => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS));
|
await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS));
|
||||||
|
|
||||||
// Force kill with SIGKILL if still alive
|
// Force kill with SIGKILL if still alive
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -18,7 +18,16 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkS
|
|||||||
import { writeFile, rename, unlink, copyFile, access } from 'node:fs/promises';
|
import { writeFile, rename, unlink, copyFile, access } from 'node:fs/promises';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { AppState, createInitialState, RalphSessionState, createInitialRalphSessionState, GlobalStats, createInitialGlobalStats, TokenStats, TokenUsageEntry } from './types.js';
|
import {
|
||||||
|
AppState,
|
||||||
|
createInitialState,
|
||||||
|
RalphSessionState,
|
||||||
|
createInitialRalphSessionState,
|
||||||
|
GlobalStats,
|
||||||
|
createInitialGlobalStats,
|
||||||
|
TokenStats,
|
||||||
|
TokenUsageEntry,
|
||||||
|
} from './types.js';
|
||||||
import { MAX_SESSION_TOKENS } from './utils/index.js';
|
import { MAX_SESSION_TOKENS } from './utils/index.js';
|
||||||
|
|
||||||
/** Debounce delay for batching state writes (ms) */
|
/** Debounce delay for batching state writes (ms) */
|
||||||
@@ -299,7 +308,11 @@ export class StateStore {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[StateStore] Failed to write state file:', err);
|
console.error('[StateStore] Failed to write state file:', err);
|
||||||
this.consecutiveSaveFailures++;
|
this.consecutiveSaveFailures++;
|
||||||
try { if (existsSync(tempPath)) unlinkSync(tempPath); } catch { /* ignore */ }
|
try {
|
||||||
|
if (existsSync(tempPath)) unlinkSync(tempPath);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||||
console.error('[StateStore] Circuit breaker OPEN - writes failing repeatedly');
|
console.error('[StateStore] Circuit breaker OPEN - writes failing repeatedly');
|
||||||
this.circuitBreakerOpen = true;
|
this.circuitBreakerOpen = true;
|
||||||
@@ -384,7 +397,10 @@ export class StateStore {
|
|||||||
* @param activeSessionIds - Set of currently active session IDs
|
* @param activeSessionIds - Set of currently active session IDs
|
||||||
* @returns Number of sessions cleaned up
|
* @returns Number of sessions cleaned up
|
||||||
*/
|
*/
|
||||||
cleanupStaleSessions(activeSessionIds: Set<string>): { count: number; cleaned: Array<{ id: string; name?: string }> } {
|
cleanupStaleSessions(activeSessionIds: Set<string>): {
|
||||||
|
count: number;
|
||||||
|
cleaned: Array<{ id: string; name?: string }>;
|
||||||
|
} {
|
||||||
const allSessionIds = Object.keys(this.state.sessions);
|
const allSessionIds = Object.keys(this.state.sessions);
|
||||||
const cleaned: Array<{ id: string; name?: string }> = [];
|
const cleaned: Array<{ id: string; name?: string }> = [];
|
||||||
|
|
||||||
@@ -481,7 +497,9 @@ export class StateStore {
|
|||||||
}
|
}
|
||||||
// Reject negative values
|
// Reject negative values
|
||||||
if (inputTokens < 0 || outputTokens < 0 || cost < 0) {
|
if (inputTokens < 0 || outputTokens < 0 || cost < 0) {
|
||||||
console.warn(`[StateStore] Rejected negative global stats: input=${inputTokens}, output=${outputTokens}, cost=${cost}`);
|
console.warn(
|
||||||
|
`[StateStore] Rejected negative global stats: input=${inputTokens}, output=${outputTokens}, cost=${cost}`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,7 +523,9 @@ export class StateStore {
|
|||||||
* Returns aggregate stats combining global (deleted sessions) + active sessions.
|
* Returns aggregate stats combining global (deleted sessions) + active sessions.
|
||||||
* @param activeSessions Map of active session states
|
* @param activeSessions Map of active session states
|
||||||
*/
|
*/
|
||||||
getAggregateStats(activeSessions: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }>): {
|
getAggregateStats(
|
||||||
|
activeSessions: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }>
|
||||||
|
): {
|
||||||
totalInputTokens: number;
|
totalInputTokens: number;
|
||||||
totalOutputTokens: number;
|
totalOutputTokens: number;
|
||||||
totalCost: number;
|
totalCost: number;
|
||||||
@@ -602,7 +622,7 @@ export class StateStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find or create today's entry
|
// Find or create today's entry
|
||||||
let todayEntry = stats.daily.find(e => e.date === today);
|
let todayEntry = stats.daily.find((e) => e.date === today);
|
||||||
if (!todayEntry) {
|
if (!todayEntry) {
|
||||||
todayEntry = {
|
todayEntry = {
|
||||||
date: today,
|
date: today,
|
||||||
@@ -617,10 +637,7 @@ export class StateStore {
|
|||||||
// Accumulate tokens
|
// Accumulate tokens
|
||||||
todayEntry.inputTokens += inputTokens;
|
todayEntry.inputTokens += inputTokens;
|
||||||
todayEntry.outputTokens += outputTokens;
|
todayEntry.outputTokens += outputTokens;
|
||||||
todayEntry.estimatedCost = this.calculateEstimatedCost(
|
todayEntry.estimatedCost = this.calculateEstimatedCost(todayEntry.inputTokens, todayEntry.outputTokens);
|
||||||
todayEntry.inputTokens,
|
|
||||||
todayEntry.outputTokens
|
|
||||||
);
|
|
||||||
|
|
||||||
// Only increment session count for unique sessions
|
// Only increment session count for unique sessions
|
||||||
if (sessionId && !this.dailySessionIds.has(sessionId)) {
|
if (sessionId && !this.dailySessionIds.has(sessionId)) {
|
||||||
|
|||||||
@@ -77,16 +77,18 @@ export interface SubagentTranscriptEntry {
|
|||||||
input_tokens?: number;
|
input_tokens?: number;
|
||||||
output_tokens?: number;
|
output_tokens?: number;
|
||||||
};
|
};
|
||||||
content: string | Array<{
|
content:
|
||||||
type: 'text' | 'tool_use' | 'tool_result';
|
| string
|
||||||
text?: string;
|
| Array<{
|
||||||
name?: string;
|
type: 'text' | 'tool_use' | 'tool_result';
|
||||||
id?: string; // tool_use_id for tool_use blocks
|
text?: string;
|
||||||
tool_use_id?: string; // tool_use_id for tool_result blocks
|
name?: string;
|
||||||
input?: Record<string, unknown>;
|
id?: string; // tool_use_id for tool_use blocks
|
||||||
content?: string | Array<{ type: string; text?: string }>; // tool_result content
|
tool_use_id?: string; // tool_use_id for tool_result blocks
|
||||||
is_error?: boolean; // For tool_result errors
|
input?: Record<string, unknown>;
|
||||||
}>;
|
content?: string | Array<{ type: string; text?: string }>; // tool_result content
|
||||||
|
is_error?: boolean; // For tool_result errors
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
data?: {
|
data?: {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -135,11 +137,11 @@ const MAX_TRACKED_AGENTS = 500; // Maximum agents to track (LRU eviction when ex
|
|||||||
|
|
||||||
// Internal Claude Code agent patterns to filter out (not real user-initiated subagents)
|
// Internal Claude Code agent patterns to filter out (not real user-initiated subagents)
|
||||||
const INTERNAL_AGENT_PATTERNS = [
|
const INTERNAL_AGENT_PATTERNS = [
|
||||||
/^\[?SUGGESTION MODE/i, // Claude Code's internal suggestion mode
|
/^\[?SUGGESTION MODE/i, // Claude Code's internal suggestion mode
|
||||||
/^Suggest what user might/i, // Suggestion mode prompt variant
|
/^Suggest what user might/i, // Suggestion mode prompt variant
|
||||||
/aprompt/i, // Internal prompt agent (anywhere in string)
|
/aprompt/i, // Internal prompt agent (anywhere in string)
|
||||||
/^a\s?prompt/i, // Variants of internal prompt agent
|
/^a\s?prompt/i, // Variants of internal prompt agent
|
||||||
/^prompt$/i, // Just "prompt"
|
/^prompt$/i, // Just "prompt"
|
||||||
];
|
];
|
||||||
|
|
||||||
// Minimum description length - very short descriptions are likely internal or malformed
|
// Minimum description length - very short descriptions are likely internal or malformed
|
||||||
@@ -194,7 +196,7 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
if (!description) return false;
|
if (!description) return false;
|
||||||
// Filter out very short descriptions (likely internal or malformed)
|
// Filter out very short descriptions (likely internal or malformed)
|
||||||
if (description.length < MIN_DESCRIPTION_LENGTH) return true;
|
if (description.length < MIN_DESCRIPTION_LENGTH) return true;
|
||||||
return INTERNAL_AGENT_PATTERNS.some(pattern => pattern.test(description));
|
return INTERNAL_AGENT_PATTERNS.some((pattern) => pattern.test(description));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -263,7 +265,7 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
if (await this.checkSubagentFileAlive(info)) continue;
|
if (await this.checkSubagentFileAlive(info)) continue;
|
||||||
|
|
||||||
// Tier 2: Cached PID check (~0.1ms per agent)
|
// Tier 2: Cached PID check (~0.1ms per agent)
|
||||||
if (info.pid && await this.checkPidAlive(info.pid)) continue;
|
if (info.pid && (await this.checkPidAlive(info.pid))) continue;
|
||||||
|
|
||||||
// Tiers 1+2 failed — need expensive scan for this agent
|
// Tiers 1+2 failed — need expensive scan for this agent
|
||||||
needsFullScan.push(info);
|
needsFullScan.push(info);
|
||||||
@@ -322,20 +324,35 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
resolve(stdout);
|
resolve(stdout);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const pids = pgrepOutput.trim().split('\n').filter(Boolean).map(s => parseInt(s, 10)).filter(n => !Number.isNaN(n));
|
const pids = pgrepOutput
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((s) => parseInt(s, 10))
|
||||||
|
.filter((n) => !Number.isNaN(n));
|
||||||
|
|
||||||
// Read /proc for all PIDs in parallel
|
// Read /proc for all PIDs in parallel
|
||||||
await Promise.all(pids.map(async (pid) => {
|
await Promise.all(
|
||||||
let environ = '';
|
pids.map(async (pid) => {
|
||||||
let cmdline = '';
|
let environ = '';
|
||||||
try { environ = await readFile(`/proc/${pid}/environ`, 'utf8'); } catch { /* skip */ }
|
let cmdline = '';
|
||||||
try { cmdline = await readFile(`/proc/${pid}/cmdline`, 'utf8'); } catch { /* skip */ }
|
try {
|
||||||
// Skip main Codeman-managed Claude processes — only track subagents
|
environ = await readFile(`/proc/${pid}/environ`, 'utf8');
|
||||||
if (environ.includes('CODEMAN_MUX=1')) return;
|
} catch {
|
||||||
if (environ || cmdline) {
|
/* skip */
|
||||||
result.set(pid, { environ, cmdline });
|
}
|
||||||
}
|
try {
|
||||||
}));
|
cmdline = await readFile(`/proc/${pid}/cmdline`, 'utf8');
|
||||||
|
} catch {
|
||||||
|
/* skip */
|
||||||
|
}
|
||||||
|
// Skip main Codeman-managed Claude processes — only track subagents
|
||||||
|
if (environ.includes('CODEMAN_MUX=1')) return;
|
||||||
|
if (environ || cmdline) {
|
||||||
|
result.set(pid, { environ, cmdline });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// Update cached PIDs on tracked agents
|
// Update cached PIDs on tracked agents
|
||||||
for (const [pid, procInfo] of result) {
|
for (const [pid, procInfo] of result) {
|
||||||
@@ -567,9 +584,7 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
getSubagentsForSession(workingDir: string): SubagentInfo[] {
|
getSubagentsForSession(workingDir: string): SubagentInfo[] {
|
||||||
const projectHash = this.getProjectHash(workingDir);
|
const projectHash = this.getProjectHash(workingDir);
|
||||||
return Array.from(this.agentInfo.values()).filter(
|
return Array.from(this.agentInfo.values()).filter((info) => info.projectHash === projectHash);
|
||||||
(info) => info.projectHash === projectHash
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -815,7 +830,8 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
} else if (content.type === 'text' && content.text) {
|
} else if (content.type === 'text' && content.text) {
|
||||||
const text = content.text.trim();
|
const text = content.text.trim();
|
||||||
if (text.length > 0) {
|
if (text.length > 0) {
|
||||||
const preview = text.length > TEXT_PREVIEW_LENGTH ? text.substring(0, TEXT_PREVIEW_LENGTH) + '...' : text;
|
const preview =
|
||||||
|
text.length > TEXT_PREVIEW_LENGTH ? text.substring(0, TEXT_PREVIEW_LENGTH) + '...' : text;
|
||||||
lines.push(`${this.formatTime(entry.timestamp)} 💬 ${preview.replace(/\n/g, ' ')}`);
|
lines.push(`${this.formatTime(entry.timestamp)} 💬 ${preview.replace(/\n/g, ' ')}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -860,7 +876,7 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
const MAX_LEN = SMART_TITLE_MAX_LENGTH;
|
const MAX_LEN = SMART_TITLE_MAX_LENGTH;
|
||||||
|
|
||||||
// Get first line/sentence
|
// Get first line/sentence
|
||||||
let title = text.split('\n')[0].trim();
|
const title = text.split('\n')[0].trim();
|
||||||
|
|
||||||
// If already short enough, use it
|
// If already short enough, use it
|
||||||
if (title.length <= MAX_LEN) {
|
if (title.length <= MAX_LEN) {
|
||||||
@@ -932,14 +948,18 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
|
|
||||||
// Check cache first (covers burst of simultaneous agent discoveries)
|
// Check cache first (covers burst of simultaneous agent discoveries)
|
||||||
const cached = this.parentDescriptionCache.get(cacheKey);
|
const cached = this.parentDescriptionCache.get(cacheKey);
|
||||||
if (cached && (Date.now() - cached.timestamp) < CACHE_TTL_MS) {
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||||
return cached.descriptions.get(agentId);
|
return cached.descriptions.get(agentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// The parent session's transcript is at: ~/.claude/projects/{projectHash}/{sessionId}.jsonl
|
// The parent session's transcript is at: ~/.claude/projects/{projectHash}/{sessionId}.jsonl
|
||||||
const transcriptPath = join(CLAUDE_PROJECTS_DIR, projectHash, `${sessionId}.jsonl`);
|
const transcriptPath = join(CLAUDE_PROJECTS_DIR, projectHash, `${sessionId}.jsonl`);
|
||||||
try { await statAsync(transcriptPath); } catch { return undefined; }
|
try {
|
||||||
|
await statAsync(transcriptPath);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const content = await readFile(transcriptPath, 'utf8');
|
const content = await readFile(transcriptPath, 'utf8');
|
||||||
const lines = content.split('\n').filter((l) => l.trim());
|
const lines = content.split('\n').filter((l) => l.trim());
|
||||||
@@ -949,11 +969,7 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
try {
|
try {
|
||||||
const entry = JSON.parse(line);
|
const entry = JSON.parse(line);
|
||||||
if (
|
if (entry.type === 'user' && entry.toolUseResult?.agentId && entry.toolUseResult?.description) {
|
||||||
entry.type === 'user' &&
|
|
||||||
entry.toolUseResult?.agentId &&
|
|
||||||
entry.toolUseResult?.description
|
|
||||||
) {
|
|
||||||
descriptions.set(entry.toolUseResult.agentId, entry.toolUseResult.description);
|
descriptions.set(entry.toolUseResult.agentId, entry.toolUseResult.description);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -982,7 +998,11 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
let lineCount = 0;
|
let lineCount = 0;
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
rl.on('line', (line) => {
|
rl.on('line', (line) => {
|
||||||
if (resolved || lineCount >= 5) { rl.close(); stream.destroy(); return; }
|
if (resolved || lineCount >= 5) {
|
||||||
|
rl.close();
|
||||||
|
stream.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
lineCount++;
|
lineCount++;
|
||||||
if (!line.trim()) return;
|
if (!line.trim()) return;
|
||||||
try {
|
try {
|
||||||
@@ -1008,8 +1028,12 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
// Skip malformed lines
|
// Skip malformed lines
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
rl.on('close', () => { if (!resolved) resolve(undefined); });
|
rl.on('close', () => {
|
||||||
rl.on('error', () => { if (!resolved) resolve(undefined); });
|
if (!resolved) resolve(undefined);
|
||||||
|
});
|
||||||
|
rl.on('error', () => {
|
||||||
|
if (!resolved) resolve(undefined);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Failed to read file
|
// Failed to read file
|
||||||
@@ -1021,7 +1045,11 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
* Scan for all subagent directories (async to avoid blocking event loop)
|
* Scan for all subagent directories (async to avoid blocking event loop)
|
||||||
*/
|
*/
|
||||||
private async scanForSubagents(): Promise<void> {
|
private async scanForSubagents(): Promise<void> {
|
||||||
try { await statAsync(CLAUDE_PROJECTS_DIR); } catch { return; }
|
try {
|
||||||
|
await statAsync(CLAUDE_PROJECTS_DIR);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const projects = await readdir(CLAUDE_PROJECTS_DIR);
|
const projects = await readdir(CLAUDE_PROJECTS_DIR);
|
||||||
@@ -1123,7 +1151,12 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
* @param sessionId Claude session ID
|
* @param sessionId Claude session ID
|
||||||
* @param isInitialScan If true, skip files older than STARTUP_MAX_FILE_AGE_MS
|
* @param isInitialScan If true, skip files older than STARTUP_MAX_FILE_AGE_MS
|
||||||
*/
|
*/
|
||||||
private async watchAgentFile(filePath: string, projectHash: string, sessionId: string, isInitialScan: boolean = false): Promise<void> {
|
private async watchAgentFile(
|
||||||
|
filePath: string,
|
||||||
|
projectHash: string,
|
||||||
|
sessionId: string,
|
||||||
|
isInitialScan: boolean = false
|
||||||
|
): Promise<void> {
|
||||||
if (this.fileWatchers.has(filePath)) return;
|
if (this.fileWatchers.has(filePath)) return;
|
||||||
|
|
||||||
const agentId = basename(filePath).replace('agent-', '').replace('.jsonl', '');
|
const agentId = basename(filePath).replace('agent-', '').replace('.jsonl', '');
|
||||||
@@ -1192,12 +1225,14 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
this.emit('subagent:discovered', info);
|
this.emit('subagent:discovered', info);
|
||||||
|
|
||||||
// Read existing content
|
// Read existing content
|
||||||
this.tailFile(filePath, agentId, sessionId, 0).then((position) => {
|
this.tailFile(filePath, agentId, sessionId, 0)
|
||||||
this.filePositions.set(filePath, position);
|
.then((position) => {
|
||||||
}).catch((err) => {
|
this.filePositions.set(filePath, position);
|
||||||
// Log but don't throw - non-critical background operation
|
})
|
||||||
console.warn(`[SubagentWatcher] Failed to read initial content for ${agentId}:`, err);
|
.catch((err) => {
|
||||||
});
|
// Log but don't throw - non-critical background operation
|
||||||
|
console.warn(`[SubagentWatcher] Failed to read initial content for ${agentId}:`, err);
|
||||||
|
});
|
||||||
|
|
||||||
// Watch for changes
|
// Watch for changes
|
||||||
try {
|
try {
|
||||||
@@ -1269,12 +1304,7 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Tail a file from a specific position
|
* Tail a file from a specific position
|
||||||
*/
|
*/
|
||||||
private async tailFile(
|
private async tailFile(filePath: string, agentId: string, sessionId: string, fromPosition: number): Promise<number> {
|
||||||
filePath: string,
|
|
||||||
agentId: string,
|
|
||||||
sessionId: string,
|
|
||||||
fromPosition: number
|
|
||||||
): Promise<number> {
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let position = fromPosition;
|
let position = fromPosition;
|
||||||
|
|
||||||
@@ -1337,11 +1367,7 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
// Check if this is first user message and description is missing
|
// Check if this is first user message and description is missing
|
||||||
if (info && !info.description && entry.type === 'user' && entry.message?.content) {
|
if (info && !info.description && entry.type === 'user' && entry.message?.content) {
|
||||||
// First try parent transcript (most reliable)
|
// First try parent transcript (most reliable)
|
||||||
let description = await this.extractDescriptionFromParentTranscript(
|
let description = await this.extractDescriptionFromParentTranscript(info.projectHash, info.sessionId, agentId);
|
||||||
info.projectHash,
|
|
||||||
info.sessionId,
|
|
||||||
agentId
|
|
||||||
);
|
|
||||||
// Fallback: extract smart title from the prompt content
|
// Fallback: extract smart title from the prompt content
|
||||||
if (!description) {
|
if (!description) {
|
||||||
let text: string | undefined;
|
let text: string | undefined;
|
||||||
@@ -1378,9 +1404,11 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
resultCount: entry.data.resultCount,
|
resultCount: entry.data.resultCount,
|
||||||
// Extract hook event info if present
|
// Extract hook event info if present
|
||||||
hookEvent: entry.data.hookEvent,
|
hookEvent: entry.data.hookEvent,
|
||||||
hookName: entry.data.hookName || (entry.data.hookEvent && entry.data.tool_name
|
hookName:
|
||||||
? `${entry.data.hookEvent}:${entry.data.tool_name}`
|
entry.data.hookName ||
|
||||||
: undefined),
|
(entry.data.hookEvent && entry.data.tool_name
|
||||||
|
? `${entry.data.hookEvent}:${entry.data.tool_name}`
|
||||||
|
: undefined),
|
||||||
};
|
};
|
||||||
this.emit('subagent:progress', progress);
|
this.emit('subagent:progress', progress);
|
||||||
} else if (entry.type === 'assistant' && entry.message?.content) {
|
} else if (entry.type === 'assistant' && entry.message?.content) {
|
||||||
@@ -1531,8 +1559,8 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
if (typeof content === 'string') return content;
|
if (typeof content === 'string') return content;
|
||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
return content
|
return content
|
||||||
.filter(c => c.type === 'text' && c.text)
|
.filter((c) => c.type === 'text' && c.text)
|
||||||
.map(c => c.text)
|
.map((c) => c.text)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
|
|||||||
@@ -196,7 +196,9 @@ export class TaskQueue extends EventEmitter {
|
|||||||
private validateDependencies(taskId: string, dependencies: string[]): void {
|
private validateDependencies(taskId: string, dependencies: string[]): void {
|
||||||
for (const depId of dependencies) {
|
for (const depId of dependencies) {
|
||||||
if (this.wouldCreateCycle(taskId, depId)) {
|
if (this.wouldCreateCycle(taskId, depId)) {
|
||||||
throw new Error(`Circular dependency detected: adding dependency ${depId} to task ${taskId} would create a cycle`);
|
throw new Error(
|
||||||
|
`Circular dependency detected: adding dependency ${depId} to task ${taskId} would create a cycle`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,14 +210,21 @@ export class TaskQueue extends EventEmitter {
|
|||||||
|
|
||||||
/** Gets the currently running task for a session, if any. */
|
/** Gets the currently running task for a session, if any. */
|
||||||
getRunningTaskForSession(sessionId: string): Task | null {
|
getRunningTaskForSession(sessionId: string): Task | null {
|
||||||
return this.getAllTasks().find(
|
return this.getAllTasks().find((t) => t.isRunning() && t.assignedSessionId === sessionId) || null;
|
||||||
(t) => t.isRunning() && t.assignedSessionId === sessionId
|
|
||||||
) || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gets counts of tasks by status (single-pass). */
|
/** Gets counts of tasks by status (single-pass). */
|
||||||
getCount(): { total: number; pending: number; running: number; completed: number; failed: number } {
|
getCount(): {
|
||||||
let pending = 0, running = 0, completed = 0, failed = 0;
|
total: number;
|
||||||
|
pending: number;
|
||||||
|
running: number;
|
||||||
|
completed: number;
|
||||||
|
failed: number;
|
||||||
|
} {
|
||||||
|
let pending = 0,
|
||||||
|
running = 0,
|
||||||
|
completed = 0,
|
||||||
|
failed = 0;
|
||||||
for (const task of this.tasks.values()) {
|
for (const task of this.tasks.values()) {
|
||||||
if (task.isPending()) pending++;
|
if (task.isPending()) pending++;
|
||||||
else if (task.isRunning()) running++;
|
else if (task.isRunning()) running++;
|
||||||
@@ -258,7 +267,6 @@ export class TaskQueue extends EventEmitter {
|
|||||||
this.tasks.clear();
|
this.tasks.clear();
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance
|
// Singleton instance
|
||||||
|
|||||||
@@ -51,21 +51,13 @@ const MAX_PENDING_TOOL_USES = 100;
|
|||||||
* Used as fallback when JSON parsing doesn't capture the launch.
|
* Used as fallback when JSON parsing doesn't capture the launch.
|
||||||
* Capture group 1: Agent/task type name
|
* Capture group 1: Agent/task type name
|
||||||
*/
|
*/
|
||||||
const LAUNCH_PATTERNS = [
|
const LAUNCH_PATTERNS = [/Launching\s+(\w+)\s+agent/i, /Starting\s+(\w+)\s+task/i, /Spawning\s+(\w+)\s+agent/i];
|
||||||
/Launching\s+(\w+)\s+agent/i,
|
|
||||||
/Starting\s+(\w+)\s+task/i,
|
|
||||||
/Spawning\s+(\w+)\s+agent/i,
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Patterns that indicate a task has completed.
|
* Patterns that indicate a task has completed.
|
||||||
* Used as fallback when JSON parsing doesn't capture the result.
|
* Used as fallback when JSON parsing doesn't capture the result.
|
||||||
*/
|
*/
|
||||||
const COMPLETE_PATTERNS = [
|
const COMPLETE_PATTERNS = [/Task\s+completed/i, /Agent\s+finished/i, /Background\s+task\s+done/i];
|
||||||
/Task\s+completed/i,
|
|
||||||
/Agent\s+finished/i,
|
|
||||||
/Background\s+task\s+done/i,
|
|
||||||
];
|
|
||||||
|
|
||||||
// ========== Type Definitions ==========
|
// ========== Type Definitions ==========
|
||||||
|
|
||||||
@@ -218,7 +210,10 @@ export class TaskTracker extends EventEmitter {
|
|||||||
private taskStack: string[] = [];
|
private taskStack: string[] = [];
|
||||||
|
|
||||||
/** Pending tool_use blocks waiting for results (with timestamp for cleanup) */
|
/** Pending tool_use blocks waiting for results (with timestamp for cleanup) */
|
||||||
private pendingToolUses: Map<string, { description: string; subagentType: string; parentId: string | null; createdAt: number }> = new Map();
|
private pendingToolUses: Map<
|
||||||
|
string,
|
||||||
|
{ description: string; subagentType: string; parentId: string | null; createdAt: number }
|
||||||
|
> = new Map();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new TaskTracker instance.
|
* Creates a new TaskTracker instance.
|
||||||
@@ -316,7 +311,12 @@ export class TaskTracker extends EventEmitter {
|
|||||||
const parentId = this.taskStack.length > 0 ? this.taskStack[this.taskStack.length - 1] : null;
|
const parentId = this.taskStack.length > 0 ? this.taskStack[this.taskStack.length - 1] : null;
|
||||||
|
|
||||||
// Store pending tool use - task starts when we see activity
|
// Store pending tool use - task starts when we see activity
|
||||||
this.pendingToolUses.set(toolUseId, { description, subagentType, parentId, createdAt: Date.now() });
|
this.pendingToolUses.set(toolUseId, {
|
||||||
|
description,
|
||||||
|
subagentType,
|
||||||
|
parentId,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
// Clean up old pending entries to prevent unbounded growth
|
// Clean up old pending entries to prevent unbounded growth
|
||||||
this.cleanupOldPendingToolUses();
|
this.cleanupOldPendingToolUses();
|
||||||
@@ -361,9 +361,7 @@ export class TaskTracker extends EventEmitter {
|
|||||||
if (task) {
|
if (task) {
|
||||||
task.status = block.is_error ? 'failed' : 'completed';
|
task.status = block.is_error ? 'failed' : 'completed';
|
||||||
task.endTime = Date.now();
|
task.endTime = Date.now();
|
||||||
task.output = typeof block.content === 'string'
|
task.output = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
|
||||||
? block.content
|
|
||||||
: JSON.stringify(block.content);
|
|
||||||
|
|
||||||
// Remove from stack
|
// Remove from stack
|
||||||
const stackIndex = this.taskStack.indexOf(toolUseId);
|
const stackIndex = this.taskStack.indexOf(toolUseId);
|
||||||
@@ -610,13 +608,21 @@ export class TaskTracker extends EventEmitter {
|
|||||||
* - failed: Tasks that ended with errors
|
* - failed: Tasks that ended with errors
|
||||||
*/
|
*/
|
||||||
getStats(): { total: number; running: number; completed: number; failed: number } {
|
getStats(): { total: number; running: number; completed: number; failed: number } {
|
||||||
let running = 0, completed = 0, failed = 0;
|
let running = 0,
|
||||||
|
completed = 0,
|
||||||
|
failed = 0;
|
||||||
|
|
||||||
for (const task of this.tasks.values()) {
|
for (const task of this.tasks.values()) {
|
||||||
switch (task.status) {
|
switch (task.status) {
|
||||||
case 'running': running++; break;
|
case 'running':
|
||||||
case 'completed': completed++; break;
|
running++;
|
||||||
case 'failed': failed++; break;
|
break;
|
||||||
|
case 'completed':
|
||||||
|
completed++;
|
||||||
|
break;
|
||||||
|
case 'failed':
|
||||||
|
failed++;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
assertNever(task.status, `Unhandled BackgroundTask status: ${task.status}`);
|
assertNever(task.status, `Unhandled BackgroundTask status: ${task.status}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,13 +86,13 @@ export class TeamWatcher extends EventEmitter {
|
|||||||
getTeamTasks(teamName: string): TeamTask[] {
|
getTeamTasks(teamName: string): TeamTask[] {
|
||||||
const tasks = this.teamTasks.get(teamName);
|
const tasks = this.teamTasks.get(teamName);
|
||||||
if (!tasks) return [];
|
if (!tasks) return [];
|
||||||
return tasks.filter(t => !t.metadata?._internal);
|
return tasks.filter((t) => !t.metadata?._internal);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Count active (non-completed) tasks for a team */
|
/** Count active (non-completed) tasks for a team */
|
||||||
getActiveTaskCount(teamName: string): number {
|
getActiveTaskCount(teamName: string): number {
|
||||||
const tasks = this.getTeamTasks(teamName);
|
const tasks = this.getTeamTasks(teamName);
|
||||||
return tasks.filter(t => t.status !== 'completed').length;
|
return tasks.filter((t) => t.status !== 'completed').length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get inbox messages for a team member (or all members) */
|
/** Get inbox messages for a team member (or all members) */
|
||||||
@@ -108,9 +108,7 @@ export class TeamWatcher extends EventEmitter {
|
|||||||
messages.push(...msgs);
|
messages.push(...msgs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return messages.sort((a, b) =>
|
return messages.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
||||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if a session has active teammates (for idle detection) */
|
/** Check if a session has active teammates (for idle detection) */
|
||||||
@@ -119,7 +117,7 @@ export class TeamWatcher extends EventEmitter {
|
|||||||
if (!team) return false;
|
if (!team) return false;
|
||||||
|
|
||||||
// Check if any non-lead members exist (they are active by definition while present)
|
// Check if any non-lead members exist (they are active by definition while present)
|
||||||
const teammates = team.members.filter(m => m.agentType !== 'team-lead');
|
const teammates = team.members.filter((m) => m.agentType !== 'team-lead');
|
||||||
if (teammates.length === 0) return false;
|
if (teammates.length === 0) return false;
|
||||||
|
|
||||||
// Check if team has active (non-completed) tasks
|
// Check if team has active (non-completed) tasks
|
||||||
@@ -132,19 +130,17 @@ export class TeamWatcher extends EventEmitter {
|
|||||||
const team = this.teams.get(teamName);
|
const team = this.teams.get(teamName);
|
||||||
if (!team) return [];
|
if (!team) return [];
|
||||||
|
|
||||||
return team.members
|
return team.members.filter(
|
||||||
.filter((m): m is TeamMember & { tmuxPaneId: string } =>
|
(m): m is TeamMember & { tmuxPaneId: string } =>
|
||||||
m.agentType !== 'team-lead' &&
|
m.agentType !== 'team-lead' && !!m.tmuxPaneId && m.tmuxPaneId !== 'in-process'
|
||||||
!!m.tmuxPaneId &&
|
);
|
||||||
m.tmuxPaneId !== 'in-process'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get count of active teammates for a session */
|
/** Get count of active teammates for a session */
|
||||||
getActiveTeammateCount(sessionId: string): number {
|
getActiveTeammateCount(sessionId: string): number {
|
||||||
const team = this.getTeamForSession(sessionId);
|
const team = this.getTeamForSession(sessionId);
|
||||||
if (!team) return 0;
|
if (!team) return 0;
|
||||||
return team.members.filter(m => m.agentType !== 'team-lead').length;
|
return team.members.filter((m) => m.agentType !== 'team-lead').length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Private Methods ==========
|
// ========== Private Methods ==========
|
||||||
@@ -257,7 +253,7 @@ export class TeamWatcher extends EventEmitter {
|
|||||||
|
|
||||||
let taskFiles: string[];
|
let taskFiles: string[];
|
||||||
try {
|
try {
|
||||||
taskFiles = (await readdir(teamTaskDir)).filter(f => f.endsWith('.json') && f !== '.lock');
|
taskFiles = (await readdir(teamTaskDir)).filter((f) => f.endsWith('.json') && f !== '.lock');
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -285,7 +281,7 @@ export class TeamWatcher extends EventEmitter {
|
|||||||
|
|
||||||
let inboxFiles: string[];
|
let inboxFiles: string[];
|
||||||
try {
|
try {
|
||||||
inboxFiles = (await readdir(inboxDir)).filter(f => f.endsWith('.json'));
|
inboxFiles = (await readdir(inboxDir)).filter((f) => f.endsWith('.json'));
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -314,7 +310,7 @@ export class TeamWatcher extends EventEmitter {
|
|||||||
this.inboxCache.set(cacheKey, messages);
|
this.inboxCache.set(cacheKey, messages);
|
||||||
|
|
||||||
// Emit new messages — compare by timestamp to handle deletions/reordering
|
// Emit new messages — compare by timestamp to handle deletions/reordering
|
||||||
const prevTimestamps = new Set(previous?.map(m => m.timestamp) || []);
|
const prevTimestamps = new Set(previous?.map((m) => m.timestamp) || []);
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
if (!prevTimestamps.has(msg.timestamp)) {
|
if (!prevTimestamps.has(msg.timestamp)) {
|
||||||
this.emit('inboxMessage', { teamName, member: memberName, message: msg });
|
this.emit('inboxMessage', { teamName, member: memberName, message: msg });
|
||||||
|
|||||||
@@ -30,10 +30,25 @@ import { existsSync, readFileSync, mkdirSync } from 'node:fs';
|
|||||||
import { writeFile, rename } from 'node:fs/promises';
|
import { writeFile, rename } from 'node:fs/promises';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { ProcessStats, PersistedRespawnConfig, getErrorMessage, DEFAULT_NICE_CONFIG, type PaneInfo, type ClaudeMode, type SessionMode, type OpenCodeConfig } from './types.js';
|
import {
|
||||||
|
ProcessStats,
|
||||||
|
PersistedRespawnConfig,
|
||||||
|
getErrorMessage,
|
||||||
|
DEFAULT_NICE_CONFIG,
|
||||||
|
type PaneInfo,
|
||||||
|
type ClaudeMode,
|
||||||
|
type SessionMode,
|
||||||
|
type OpenCodeConfig,
|
||||||
|
} from './types.js';
|
||||||
import { wrapWithNice } from './utils/nice-wrapper.js';
|
import { wrapWithNice } from './utils/nice-wrapper.js';
|
||||||
import { SAFE_PATH_PATTERN } from './utils/regex-patterns.js';
|
import { SAFE_PATH_PATTERN } from './utils/regex-patterns.js';
|
||||||
import type { TerminalMultiplexer, MuxSession, MuxSessionWithStats, CreateSessionOptions, RespawnPaneOptions } from './mux-interface.js';
|
import type {
|
||||||
|
TerminalMultiplexer,
|
||||||
|
MuxSession,
|
||||||
|
MuxSessionWithStats,
|
||||||
|
CreateSessionOptions,
|
||||||
|
RespawnPaneOptions,
|
||||||
|
} from './mux-interface.js';
|
||||||
|
|
||||||
// Claude CLI PATH resolution — shared utility
|
// Claude CLI PATH resolution — shared utility
|
||||||
import { findClaudeDir } from './utils/claude-cli-resolver.js';
|
import { findClaudeDir } from './utils/claude-cli-resolver.js';
|
||||||
@@ -103,11 +118,23 @@ function isValidMuxName(name: string): boolean {
|
|||||||
* Prevents command injection via malformed paths.
|
* Prevents command injection via malformed paths.
|
||||||
*/
|
*/
|
||||||
function isValidPath(path: string): boolean {
|
function isValidPath(path: string): boolean {
|
||||||
if (path.includes(';') || path.includes('&') || path.includes('|') ||
|
if (
|
||||||
path.includes('$') || path.includes('`') || path.includes('(') ||
|
path.includes(';') ||
|
||||||
path.includes(')') || path.includes('{') || path.includes('}') ||
|
path.includes('&') ||
|
||||||
path.includes('<') || path.includes('>') || path.includes("'") ||
|
path.includes('|') ||
|
||||||
path.includes('"') || path.includes('\n') || path.includes('\r')) {
|
path.includes('$') ||
|
||||||
|
path.includes('`') ||
|
||||||
|
path.includes('(') ||
|
||||||
|
path.includes(')') ||
|
||||||
|
path.includes('{') ||
|
||||||
|
path.includes('}') ||
|
||||||
|
path.includes('<') ||
|
||||||
|
path.includes('>') ||
|
||||||
|
path.includes("'") ||
|
||||||
|
path.includes('"') ||
|
||||||
|
path.includes('\n') ||
|
||||||
|
path.includes('\r')
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (path.includes('..')) {
|
if (path.includes('..')) {
|
||||||
@@ -177,7 +204,7 @@ function buildSpawnCommand(options: {
|
|||||||
}): string {
|
}): string {
|
||||||
if (options.mode === 'claude') {
|
if (options.mode === 'claude') {
|
||||||
// Validate model to prevent command injection
|
// Validate model to prevent command injection
|
||||||
const safeModel = (options.model && /^[a-zA-Z0-9._-]+$/.test(options.model)) ? options.model : undefined;
|
const safeModel = options.model && /^[a-zA-Z0-9._-]+$/.test(options.model) ? options.model : undefined;
|
||||||
const modelFlag = safeModel ? ` --model ${safeModel}` : '';
|
const modelFlag = safeModel ? ` --model ${safeModel}` : '';
|
||||||
return `claude${buildClaudePermissionFlags(options.claudeMode, options.allowedTools)} --session-id "${options.sessionId}"${modelFlag}`;
|
return `claude${buildClaudePermissionFlags(options.claudeMode, options.allowedTools)} --session-id "${options.sessionId}"${modelFlag}`;
|
||||||
}
|
}
|
||||||
@@ -204,7 +231,9 @@ function setOpenCodeEnvVars(muxName: string): void {
|
|||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
} catch { /* Non-critical — key may not be needed */ }
|
} catch {
|
||||||
|
/* Non-critical — key may not be needed */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,7 +254,9 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi
|
|||||||
const existing = JSON.parse(config.configContent) as Record<string, unknown>;
|
const existing = JSON.parse(config.configContent) as Record<string, unknown>;
|
||||||
Object.assign(permConfig, existing);
|
Object.assign(permConfig, existing);
|
||||||
permConfig.permission = { '*': 'allow' };
|
permConfig.permission = { '*': 'allow' };
|
||||||
} catch { /* invalid JSON, use default permConfig */ }
|
} catch {
|
||||||
|
/* invalid JSON, use default permConfig */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
jsonContent = JSON.stringify(permConfig);
|
jsonContent = JSON.stringify(permConfig);
|
||||||
} else if (config.configContent) {
|
} else if (config.configContent) {
|
||||||
@@ -247,7 +278,9 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi
|
|||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
} catch { /* Non-critical */ }
|
} catch {
|
||||||
|
/* Non-critical */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,7 +427,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE');
|
if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE');
|
||||||
const envExportsStr = envExports.join(' && ');
|
const envExportsStr = envExports.join(' && ');
|
||||||
|
|
||||||
const baseCmd = buildSpawnCommand({ mode, sessionId, model, claudeMode, allowedTools, openCodeConfig });
|
const baseCmd = buildSpawnCommand({
|
||||||
|
mode,
|
||||||
|
sessionId,
|
||||||
|
model,
|
||||||
|
claudeMode,
|
||||||
|
allowedTools,
|
||||||
|
openCodeConfig,
|
||||||
|
});
|
||||||
|
|
||||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||||
const cmd = wrapWithNice(baseCmd, config);
|
const cmd = wrapWithNice(baseCmd, config);
|
||||||
@@ -412,15 +452,22 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
// (Production uses systemd which has a clean env, but dev/test may be nested.)
|
// (Production uses systemd which has a clean env, but dev/test may be nested.)
|
||||||
const cleanEnv = { ...process.env };
|
const cleanEnv = { ...process.env };
|
||||||
delete cleanEnv.TMUX;
|
delete cleanEnv.TMUX;
|
||||||
execSync(
|
execSync(`tmux new-session -ds "${muxName}" -c "${workingDir}" -x 120 -y 40`, {
|
||||||
`tmux new-session -ds "${muxName}" -c "${workingDir}" -x 120 -y 40`,
|
cwd: workingDir,
|
||||||
{ cwd: workingDir, timeout: EXEC_TIMEOUT_MS, stdio: 'ignore', env: cleanEnv }
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
);
|
stdio: 'ignore',
|
||||||
|
env: cleanEnv,
|
||||||
|
});
|
||||||
|
|
||||||
// Set remain-on-exit now that the server is running — must be before respawn-pane
|
// Set remain-on-exit now that the server is running — must be before respawn-pane
|
||||||
try {
|
try {
|
||||||
execSync(`tmux set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS, stdio: 'ignore' });
|
execSync(`tmux set-option -t "${muxName}" remain-on-exit on`, {
|
||||||
} catch { /* Non-critical */ }
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* Non-critical */
|
||||||
|
}
|
||||||
|
|
||||||
// For OpenCode: set sensitive env vars and config via tmux setenv
|
// For OpenCode: set sensitive env vars and config via tmux setenv
|
||||||
// (not visible in ps output or tmux history, inherited by panes)
|
// (not visible in ps output or tmux history, inherited by panes)
|
||||||
@@ -430,13 +477,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Replace the shell with the actual command (no echo in terminal)
|
// Replace the shell with the actual command (no echo in terminal)
|
||||||
execSync(
|
execSync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
|
||||||
`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
{ timeout: EXEC_TIMEOUT_MS, stdio: 'ignore' }
|
stdio: 'ignore',
|
||||||
);
|
});
|
||||||
|
|
||||||
// Wait for tmux session to be queryable
|
// Wait for tmux session to be queryable
|
||||||
await new Promise(resolve => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
||||||
|
|
||||||
// Non-critical tmux config — run in parallel to avoid blocking event loop.
|
// Non-critical tmux config — run in parallel to avoid blocking event loop.
|
||||||
// These configure UX niceties (no status bar, true color).
|
// These configure UX niceties (no status bar, true color).
|
||||||
@@ -445,18 +492,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
const configPromises: Promise<void>[] = [
|
const configPromises: Promise<void>[] = [
|
||||||
// Disable tmux status bar — Codeman's web UI provides session info
|
// Disable tmux status bar — Codeman's web UI provides session info
|
||||||
execAsync(`tmux set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS })
|
execAsync(`tmux set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS })
|
||||||
.then(() => {}).catch(() => { /* Non-critical — session still works with status bar */ }),
|
.then(() => {})
|
||||||
|
.catch(() => {
|
||||||
|
/* Non-critical — session still works with status bar */
|
||||||
|
}),
|
||||||
// Override global remain-on-exit with session-level setting
|
// Override global remain-on-exit with session-level setting
|
||||||
execAsync(`tmux set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS })
|
execAsync(`tmux set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS })
|
||||||
.then(() => {}).catch(() => { /* Already set globally as fallback */ }),
|
.then(() => {})
|
||||||
|
.catch(() => {
|
||||||
|
/* Already set globally as fallback */
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Enable 24-bit true color passthrough — server-wide, set once per lifetime
|
// Enable 24-bit true color passthrough — server-wide, set once per lifetime
|
||||||
if (!this.trueColorConfigured) {
|
if (!this.trueColorConfigured) {
|
||||||
configPromises.push(
|
configPromises.push(
|
||||||
execAsync(`tmux set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS })
|
execAsync(`tmux set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS })
|
||||||
.then(() => { this.trueColorConfigured = true; })
|
.then(() => {
|
||||||
.catch(() => { /* Non-critical — colors limited to 256 */ })
|
this.trueColorConfigured = true;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* Non-critical — colors limited to 256 */
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,7 +524,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
// Get the PID of the pane process (retry for tmux server cold-start)
|
// Get the PID of the pane process (retry for tmux server cold-start)
|
||||||
let pid = this.getPanePid(muxName);
|
let pid = this.getPanePid(muxName);
|
||||||
for (let i = 0; !pid && i < GET_PID_MAX_RETRIES; i++) {
|
for (let i = 0; !pid && i < GET_PID_MAX_RETRIES; i++) {
|
||||||
await new Promise(resolve => setTimeout(resolve, GET_PID_RETRY_MS));
|
await new Promise((resolve) => setTimeout(resolve, GET_PID_RETRY_MS));
|
||||||
pid = this.getPanePid(muxName);
|
pid = this.getPanePid(muxName);
|
||||||
}
|
}
|
||||||
if (!pid) {
|
if (!pid) {
|
||||||
@@ -507,10 +564,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const output = execSync(
|
const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_pid}'`, {
|
||||||
`tmux display-message -t "${muxName}" -p '#{pane_pid}'`,
|
encoding: 'utf-8',
|
||||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
).trim();
|
}).trim();
|
||||||
const pid = parseInt(output, 10);
|
const pid = parseInt(output, 10);
|
||||||
return Number.isNaN(pid) ? null : pid;
|
return Number.isNaN(pid) ? null : pid;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -533,10 +590,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
if (IS_TEST_MODE) return false;
|
if (IS_TEST_MODE) return false;
|
||||||
if (!isValidMuxName(muxName)) return false;
|
if (!isValidMuxName(muxName)) return false;
|
||||||
try {
|
try {
|
||||||
const output = execSync(
|
const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_dead}'`, {
|
||||||
`tmux display-message -t "${muxName}" -p '#{pane_dead}'`,
|
encoding: 'utf-8',
|
||||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
).trim();
|
}).trim();
|
||||||
return output === '1';
|
return output === '1';
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -578,7 +635,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE');
|
if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE');
|
||||||
const envExportsStr = envExports.join(' && ');
|
const envExportsStr = envExports.join(' && ');
|
||||||
|
|
||||||
const baseCmd = buildSpawnCommand({ mode, sessionId, model, claudeMode, allowedTools, openCodeConfig });
|
const baseCmd = buildSpawnCommand({
|
||||||
|
mode,
|
||||||
|
sessionId,
|
||||||
|
model,
|
||||||
|
claudeMode,
|
||||||
|
allowedTools,
|
||||||
|
openCodeConfig,
|
||||||
|
});
|
||||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||||
const cmd = wrapWithNice(baseCmd, config);
|
const cmd = wrapWithNice(baseCmd, config);
|
||||||
const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`;
|
const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`;
|
||||||
@@ -590,12 +654,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
setOpenCodeConfigContent(muxName, openCodeConfig);
|
setOpenCodeConfigContent(muxName, openCodeConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
await execAsync(
|
await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
|
||||||
`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
{ timeout: EXEC_TIMEOUT_MS }
|
});
|
||||||
);
|
|
||||||
// Wait for the respawned process to start
|
// Wait for the respawned process to start
|
||||||
await new Promise(resolve => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
||||||
const pid = this.getPanePid(muxName);
|
const pid = this.getPanePid(muxName);
|
||||||
if (pid) session.pid = pid;
|
if (pid) session.pid = pid;
|
||||||
return pid;
|
return pid;
|
||||||
@@ -628,7 +691,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
}).trim();
|
}).trim();
|
||||||
if (output) {
|
if (output) {
|
||||||
for (const childPid of output.split('\n').map(p => parseInt(p, 10)).filter(p => !Number.isNaN(p))) {
|
for (const childPid of output
|
||||||
|
.split('\n')
|
||||||
|
.map((p) => parseInt(p, 10))
|
||||||
|
.filter((p) => !Number.isNaN(p))) {
|
||||||
pids.push(childPid);
|
pids.push(childPid);
|
||||||
pids.push(...this.getChildPids(childPid));
|
pids.push(...this.getChildPids(childPid));
|
||||||
}
|
}
|
||||||
@@ -655,14 +721,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
const checkInterval = 100;
|
const checkInterval = 100;
|
||||||
|
|
||||||
while (Date.now() - startTime < maxWaitMs) {
|
while (Date.now() - startTime < maxWaitMs) {
|
||||||
const aliveCount = pids.filter(pid => this.isProcessAlive(pid)).length;
|
const aliveCount = pids.filter((pid) => this.isProcessAlive(pid)).length;
|
||||||
if (aliveCount === 0) {
|
if (aliveCount === 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
await new Promise(resolve => setTimeout(resolve, checkInterval));
|
await new Promise((resolve) => setTimeout(resolve, checkInterval));
|
||||||
}
|
}
|
||||||
|
|
||||||
const stillAlive = pids.filter(pid => this.isProcessAlive(pid));
|
const stillAlive = pids.filter((pid) => this.isProcessAlive(pid));
|
||||||
if (stillAlive.length > 0) {
|
if (stillAlive.length > 0) {
|
||||||
console.warn(`[TmuxManager] ${stillAlive.length} processes still alive after kill: ${stillAlive.join(', ')}`);
|
console.warn(`[TmuxManager] ${stillAlive.length} processes still alive after kill: ${stillAlive.join(', ')}`);
|
||||||
}
|
}
|
||||||
@@ -717,7 +783,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, TMUX_KILL_WAIT_MS));
|
await new Promise((resolve) => setTimeout(resolve, TMUX_KILL_WAIT_MS));
|
||||||
|
|
||||||
childPids = this.getChildPids(currentPid);
|
childPids = this.getChildPids(currentPid);
|
||||||
for (const childPid of childPids) {
|
for (const childPid of childPids) {
|
||||||
@@ -735,7 +801,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
if (this.isProcessAlive(currentPid)) {
|
if (this.isProcessAlive(currentPid)) {
|
||||||
try {
|
try {
|
||||||
process.kill(-currentPid, 'SIGTERM');
|
process.kill(-currentPid, 'SIGTERM');
|
||||||
await new Promise(resolve => setTimeout(resolve, GRACEFUL_SHUTDOWN_WAIT_MS));
|
await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_WAIT_MS));
|
||||||
if (this.isProcessAlive(currentPid)) {
|
if (this.isProcessAlive(currentPid)) {
|
||||||
process.kill(-currentPid, 'SIGKILL');
|
process.kill(-currentPid, 'SIGKILL');
|
||||||
}
|
}
|
||||||
@@ -829,10 +895,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
|
|
||||||
// Discover unknown codeman sessions
|
// Discover unknown codeman sessions
|
||||||
try {
|
try {
|
||||||
const output = execSync(
|
const output = execSync("tmux list-sessions -F '#{session_name}' 2>/dev/null || true", {
|
||||||
"tmux list-sessions -F '#{session_name}' 2>/dev/null || true",
|
encoding: 'utf-8',
|
||||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
).trim();
|
}).trim();
|
||||||
|
|
||||||
for (const line of output.split('\n')) {
|
for (const line of output.split('\n')) {
|
||||||
const sessionName = line.trim();
|
const sessionName = line.trim();
|
||||||
@@ -890,26 +956,26 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const psOutput = execSync(
|
const psOutput = execSync(`ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`, {
|
||||||
`ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`,
|
encoding: 'utf-8',
|
||||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
).trim();
|
}).trim();
|
||||||
|
|
||||||
const [rss, cpu] = psOutput.split(/\s+/).map(x => parseFloat(x) || 0);
|
const [rss, cpu] = psOutput.split(/\s+/).map((x) => parseFloat(x) || 0);
|
||||||
|
|
||||||
let childCount = 0;
|
let childCount = 0;
|
||||||
try {
|
try {
|
||||||
const childOutput = execSync(
|
const childOutput = execSync(`pgrep -P ${session.pid} | wc -l`, {
|
||||||
`pgrep -P ${session.pid} | wc -l`,
|
encoding: 'utf-8',
|
||||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
).trim();
|
}).trim();
|
||||||
childCount = parseInt(childOutput, 10) || 0;
|
childCount = parseInt(childOutput, 10) || 0;
|
||||||
} catch {
|
} catch {
|
||||||
// No children or command failed
|
// No children or command failed
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
memoryMB: Math.round(rss / 1024 * 10) / 10,
|
memoryMB: Math.round((rss / 1024) * 10) / 10,
|
||||||
cpuPercent: Math.round(cpu * 10) / 10,
|
cpuPercent: Math.round(cpu * 10) / 10,
|
||||||
childCount,
|
childCount,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
@@ -921,7 +987,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
|
|
||||||
async getSessionsWithStats(): Promise<MuxSessionWithStats[]> {
|
async getSessionsWithStats(): Promise<MuxSessionWithStats[]> {
|
||||||
if (IS_TEST_MODE) {
|
if (IS_TEST_MODE) {
|
||||||
return Array.from(this.sessions.values()).map(s => ({
|
return Array.from(this.sessions.values()).map((s) => ({
|
||||||
...s,
|
...s,
|
||||||
stats: { memoryMB: 0, cpuPercent: 0, childCount: 0, updatedAt: Date.now() },
|
stats: { memoryMB: 0, cpuPercent: 0, childCount: 0, updatedAt: Date.now() },
|
||||||
}));
|
}));
|
||||||
@@ -932,7 +998,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionPids = sessions.map(s => s.pid);
|
const sessionPids = sessions.map((s) => s.pid);
|
||||||
const statsMap = new Map<number, ProcessStats>();
|
const statsMap = new Map<number, ProcessStats>();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -941,7 +1007,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
|
|
||||||
const pgrepOutput = execSync(
|
const pgrepOutput = execSync(
|
||||||
`for p in ${sessionPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`,
|
`for p in ${sessionPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`,
|
||||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
{
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
}
|
||||||
).trim();
|
).trim();
|
||||||
|
|
||||||
for (const line of pgrepOutput.split('\n')) {
|
for (const line of pgrepOutput.split('\n')) {
|
||||||
@@ -950,8 +1019,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
if (!Number.isNaN(sessionPid)) {
|
if (!Number.isNaN(sessionPid)) {
|
||||||
const children = (childrenStr || '')
|
const children = (childrenStr || '')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(s => parseInt(s.trim(), 10))
|
.map((s) => parseInt(s.trim(), 10))
|
||||||
.filter(n => !Number.isNaN(n) && n > 0);
|
.filter((n) => !Number.isNaN(n) && n > 0);
|
||||||
descendantMap.set(sessionPid, children);
|
descendantMap.set(sessionPid, children);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -967,10 +1036,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
// Step 3: Single ps call
|
// Step 3: Single ps call
|
||||||
const pidArray = Array.from(allPids);
|
const pidArray = Array.from(allPids);
|
||||||
if (pidArray.length > 0) {
|
if (pidArray.length > 0) {
|
||||||
const psOutput = execSync(
|
const psOutput = execSync(`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`, {
|
||||||
`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`,
|
encoding: 'utf-8',
|
||||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
).trim();
|
}).trim();
|
||||||
|
|
||||||
const processStats = new Map<number, { rss: number; cpu: number }>();
|
const processStats = new Map<number, { rss: number; cpu: number }>();
|
||||||
for (const line of psOutput.split('\n')) {
|
for (const line of psOutput.split('\n')) {
|
||||||
@@ -1002,7 +1071,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
statsMap.set(sessionPid, {
|
statsMap.set(sessionPid, {
|
||||||
memoryMB: Math.round(totalRss / 1024 * 10) / 10,
|
memoryMB: Math.round((totalRss / 1024) * 10) / 10,
|
||||||
cpuPercent: Math.round(totalCpu * 10) / 10,
|
cpuPercent: Math.round(totalCpu * 10) / 10,
|
||||||
childCount: children.length,
|
childCount: children.length,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
@@ -1011,7 +1080,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Fall back to individual queries
|
// Fall back to individual queries
|
||||||
const statsPromises = sessions.map(session => this.getProcessStats(session.sessionId));
|
const statsPromises = sessions.map((session) => this.getProcessStats(session.sessionId));
|
||||||
const results = await Promise.allSettled(statsPromises);
|
const results = await Promise.allSettled(statsPromises);
|
||||||
return sessions.map((session, i) => ({
|
return sessions.map((session, i) => ({
|
||||||
...session,
|
...session,
|
||||||
@@ -1019,7 +1088,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return sessions.map(session => ({
|
return sessions.map((session) => ({
|
||||||
...session,
|
...session,
|
||||||
stats: statsMap.get(session.pid) || undefined,
|
stats: statsMap.get(session.pid) || undefined,
|
||||||
}));
|
}));
|
||||||
@@ -1145,7 +1214,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
async sendInput(sessionId: string, input: string): Promise<boolean> {
|
async sendInput(sessionId: string, input: string): Promise<boolean> {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
console.error(`[TmuxManager] sendInput failed: no session found for ${sessionId}. Known: ${Array.from(this.sessions.keys()).join(', ')}`);
|
console.error(
|
||||||
|
`[TmuxManager] sendInput failed: no session found for ${sessionId}. Known: ${Array.from(this.sessions.keys()).join(', ')}`
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1154,7 +1225,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[TmuxManager] sendInput to ${session.muxName}, input length: ${input.length}, hasCarriageReturn: ${input.includes('\r')}`);
|
console.log(
|
||||||
|
`[TmuxManager] sendInput to ${session.muxName}, input length: ${input.length}, hasCarriageReturn: ${input.includes('\r')}`
|
||||||
|
);
|
||||||
|
|
||||||
if (!isValidMuxName(session.muxName)) {
|
if (!isValidMuxName(session.muxName)) {
|
||||||
console.error('[TmuxManager] Invalid session name in sendInput:', session.muxName);
|
console.error('[TmuxManager] Invalid session name in sendInput:', session.muxName);
|
||||||
@@ -1170,27 +1243,23 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
// Ink (Claude CLI's terminal framework) needs them split — sending both in a
|
// Ink (Claude CLI's terminal framework) needs them split — sending both in a
|
||||||
// single tmux invocation (via \;) causes Ink to interpret Enter as a newline
|
// single tmux invocation (via \;) causes Ink to interpret Enter as a newline
|
||||||
// character in the input buffer rather than as form submission.
|
// character in the input buffer rather than as form submission.
|
||||||
await execAsync(
|
await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
|
||||||
`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
{ timeout: EXEC_TIMEOUT_MS }
|
});
|
||||||
);
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
await new Promise(resolve => setTimeout(resolve, 50));
|
await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, {
|
||||||
await execAsync(
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
`tmux send-keys -t "${session.muxName}" Enter`,
|
});
|
||||||
{ timeout: EXEC_TIMEOUT_MS }
|
|
||||||
);
|
|
||||||
} else if (textPart) {
|
} else if (textPart) {
|
||||||
// Text only, no Enter
|
// Text only, no Enter
|
||||||
await execAsync(
|
await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
|
||||||
`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
{ timeout: EXEC_TIMEOUT_MS }
|
});
|
||||||
);
|
|
||||||
} else if (hasCarriageReturn) {
|
} else if (hasCarriageReturn) {
|
||||||
// Enter only
|
// Enter only
|
||||||
await execAsync(
|
await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, {
|
||||||
`tmux send-keys -t "${session.muxName}" Enter`,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
{ timeout: EXEC_TIMEOUT_MS }
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -1215,10 +1284,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(
|
execSync(`tmux set-option -t "${muxName}" mouse on`, {
|
||||||
`tmux set-option -t "${muxName}" mouse on`,
|
encoding: 'utf-8',
|
||||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
);
|
});
|
||||||
console.log(`[TmuxManager] Mouse mode ON for ${muxName}`);
|
console.log(`[TmuxManager] Mouse mode ON for ${muxName}`);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1239,10 +1308,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(
|
execSync(`tmux set-option -t "${muxName}" mouse off`, {
|
||||||
`tmux set-option -t "${muxName}" mouse off`,
|
encoding: 'utf-8',
|
||||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
);
|
});
|
||||||
console.log(`[TmuxManager] Mouse mode OFF for ${muxName}`);
|
console.log(`[TmuxManager] Mouse mode OFF for ${muxName}`);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1283,16 +1352,19 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||||
).trim();
|
).trim();
|
||||||
|
|
||||||
return output.split('\n').map(line => {
|
return output
|
||||||
const [paneId, indexStr, pidStr, widthStr, heightStr] = line.split(':');
|
.split('\n')
|
||||||
return {
|
.map((line) => {
|
||||||
paneId,
|
const [paneId, indexStr, pidStr, widthStr, heightStr] = line.split(':');
|
||||||
paneIndex: parseInt(indexStr, 10),
|
return {
|
||||||
panePid: parseInt(pidStr, 10),
|
paneId,
|
||||||
width: parseInt(widthStr, 10),
|
paneIndex: parseInt(indexStr, 10),
|
||||||
height: parseInt(heightStr, 10),
|
panePid: parseInt(pidStr, 10),
|
||||||
};
|
width: parseInt(widthStr, 10),
|
||||||
}).filter(p => !Number.isNaN(p.paneIndex));
|
height: parseInt(heightStr, 10),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((p) => !Number.isNaN(p.paneIndex));
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -1314,33 +1386,31 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build target: sessionName.paneId (e.g., "codeman-abc12345.%1")
|
// Build target: sessionName.paneId (e.g., "codeman-abc12345.%1")
|
||||||
const target = paneTarget.startsWith('%')
|
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
||||||
? `${muxName}.${paneTarget}`
|
|
||||||
: `${muxName}.%${paneTarget}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const hasCarriageReturn = input.includes('\r');
|
const hasCarriageReturn = input.includes('\r');
|
||||||
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
|
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
|
||||||
|
|
||||||
if (textPart && hasCarriageReturn) {
|
if (textPart && hasCarriageReturn) {
|
||||||
execSync(
|
execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
|
||||||
`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`,
|
encoding: 'utf-8',
|
||||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
);
|
});
|
||||||
execSync(
|
execSync(`tmux send-keys -t ${shellescape(target)} Enter`, {
|
||||||
`tmux send-keys -t ${shellescape(target)} Enter`,
|
encoding: 'utf-8',
|
||||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
);
|
});
|
||||||
} else if (textPart) {
|
} else if (textPart) {
|
||||||
execSync(
|
execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
|
||||||
`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`,
|
encoding: 'utf-8',
|
||||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
);
|
});
|
||||||
} else if (hasCarriageReturn) {
|
} else if (hasCarriageReturn) {
|
||||||
execSync(
|
execSync(`tmux send-keys -t ${shellescape(target)} Enter`, {
|
||||||
`tmux send-keys -t ${shellescape(target)} Enter`,
|
encoding: 'utf-8',
|
||||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -1365,15 +1435,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = paneTarget.startsWith('%')
|
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
||||||
? `${muxName}.${paneTarget}`
|
|
||||||
: `${muxName}.%${paneTarget}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return execSync(
|
return execSync(`tmux capture-pane -p -e -t ${shellescape(target)} -S -5000`, {
|
||||||
`tmux capture-pane -p -e -t ${shellescape(target)} -S -5000`,
|
encoding: 'utf-8',
|
||||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
);
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[TmuxManager] Failed to capture pane buffer:', err);
|
console.error('[TmuxManager] Failed to capture pane buffer:', err);
|
||||||
return null;
|
return null;
|
||||||
@@ -1399,15 +1467,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = paneTarget.startsWith('%')
|
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
||||||
? `${muxName}.${paneTarget}`
|
|
||||||
: `${muxName}.%${paneTarget}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(
|
execSync(`tmux pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, {
|
||||||
`tmux pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`,
|
encoding: 'utf-8',
|
||||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
);
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[TmuxManager] Failed to start pipe-pane:', err);
|
console.error('[TmuxManager] Failed to start pipe-pane:', err);
|
||||||
@@ -1429,15 +1495,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = paneTarget.startsWith('%')
|
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
||||||
? `${muxName}.${paneTarget}`
|
|
||||||
: `${muxName}.%${paneTarget}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(
|
execSync(`tmux pipe-pane -t ${shellescape(target)}`, {
|
||||||
`tmux pipe-pane -t ${shellescape(target)}`,
|
encoding: 'utf-8',
|
||||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
);
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[TmuxManager] Failed to stop pipe-pane:', err);
|
console.error('[TmuxManager] Failed to stop pipe-pane:', err);
|
||||||
|
|||||||
@@ -86,12 +86,7 @@ const POLL_INTERVAL_MS = 1000;
|
|||||||
const MAX_MESSAGE_LENGTH = 500;
|
const MAX_MESSAGE_LENGTH = 500;
|
||||||
|
|
||||||
/** Patterns that indicate plan mode / approval prompt */
|
/** Patterns that indicate plan mode / approval prompt */
|
||||||
const PLAN_MODE_PATTERNS = [
|
const PLAN_MODE_PATTERNS = [/ExitPlanMode/i, /AskUserQuestion/i, /Ready for user approval/i, /approve.*plan/i];
|
||||||
/ExitPlanMode/i,
|
|
||||||
/AskUserQuestion/i,
|
|
||||||
/Ready for user approval/i,
|
|
||||||
/approve.*plan/i,
|
|
||||||
];
|
|
||||||
|
|
||||||
// ========== TranscriptWatcher Class ==========
|
// ========== TranscriptWatcher Class ==========
|
||||||
|
|
||||||
@@ -410,12 +405,13 @@ export class TranscriptWatcher extends EventEmitter {
|
|||||||
if (entry.type !== 'assistant' || !entry.message?.content) return;
|
if (entry.type !== 'assistant' || !entry.message?.content) return;
|
||||||
|
|
||||||
const content = entry.message.content;
|
const content = entry.message.content;
|
||||||
const textToCheck = typeof content === 'string'
|
const textToCheck =
|
||||||
? content
|
typeof content === 'string'
|
||||||
: content
|
? content
|
||||||
.filter((b): b is { type: 'text'; text: string } => b.type === 'text' && !!b.text)
|
: content
|
||||||
.map(b => b.text)
|
.filter((b): b is { type: 'text'; text: string } => b.type === 'text' && !!b.text)
|
||||||
.join(' ');
|
.map((b) => b.text)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
// Also check for tool_use with ExitPlanMode or AskUserQuestion
|
// Also check for tool_use with ExitPlanMode or AskUserQuestion
|
||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
|
|||||||
@@ -112,7 +112,10 @@ export class TunnelManager extends EventEmitter {
|
|||||||
|
|
||||||
const binary = this.resolveCloudflared();
|
const binary = this.resolveCloudflared();
|
||||||
if (!binary) {
|
if (!binary) {
|
||||||
this.emit('error', 'cloudflared not found. Install from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/');
|
this.emit(
|
||||||
|
'error',
|
||||||
|
'cloudflared not found. Install from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,16 +80,16 @@ export type TddPhase = 'setup' | 'test' | 'impl' | 'verify' | 'review';
|
|||||||
|
|
||||||
/** Types of session lifecycle events recorded to the audit log */
|
/** Types of session lifecycle events recorded to the audit log */
|
||||||
export type LifecycleEventType =
|
export type LifecycleEventType =
|
||||||
| 'created' // Session object created
|
| 'created' // Session object created
|
||||||
| 'started' // PTY process launched (interactive/shell/prompt)
|
| 'started' // PTY process launched (interactive/shell/prompt)
|
||||||
| 'exit' // PTY process exited (with exit code)
|
| 'exit' // PTY process exited (with exit code)
|
||||||
| 'deleted' // cleanupSession() called — session removed
|
| 'deleted' // cleanupSession() called — session removed
|
||||||
| 'detached' // Server shutdown — PTY left alive in tmux for recovery
|
| 'detached' // Server shutdown — PTY left alive in tmux for recovery
|
||||||
| 'recovered' // Session restored from tmux on server restart
|
| 'recovered' // Session restored from tmux on server restart
|
||||||
| 'stale_cleaned' // Removed from state.json by cleanupStaleSessions()
|
| 'stale_cleaned' // Removed from state.json by cleanupStaleSessions()
|
||||||
| 'mux_died' // tmux session died (detected by reconciliation)
|
| 'mux_died' // tmux session died (detected by reconciliation)
|
||||||
| 'server_started' // Server started (marker for restart detection)
|
| 'server_started' // Server started (marker for restart detection)
|
||||||
| 'server_stopped'; // Server shutting down
|
| 'server_stopped'; // Server shutting down
|
||||||
|
|
||||||
/** A single entry in the session lifecycle audit log */
|
/** A single entry in the session lifecycle audit log */
|
||||||
export interface LifecycleEntry {
|
export interface LifecycleEntry {
|
||||||
@@ -145,15 +145,7 @@ export interface SessionConfig {
|
|||||||
/**
|
/**
|
||||||
* Available session colors for visual differentiation
|
* Available session colors for visual differentiation
|
||||||
*/
|
*/
|
||||||
export type SessionColor =
|
export type SessionColor = 'default' | 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' | 'pink';
|
||||||
| 'default'
|
|
||||||
| 'red'
|
|
||||||
| 'orange'
|
|
||||||
| 'yellow'
|
|
||||||
| 'green'
|
|
||||||
| 'blue'
|
|
||||||
| 'purple'
|
|
||||||
| 'pink';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Current state of a session
|
* Current state of a session
|
||||||
@@ -472,11 +464,11 @@ export interface RespawnConfig {
|
|||||||
* Outcome of a respawn cycle
|
* Outcome of a respawn cycle
|
||||||
*/
|
*/
|
||||||
export type CycleOutcome =
|
export type CycleOutcome =
|
||||||
| 'success' // Cycle completed normally
|
| 'success' // Cycle completed normally
|
||||||
| 'stuck_recovery' // Stuck-state recovery triggered
|
| 'stuck_recovery' // Stuck-state recovery triggered
|
||||||
| 'blocked' // Blocked by circuit breaker or exit signal
|
| 'blocked' // Blocked by circuit breaker or exit signal
|
||||||
| 'error' // Error during cycle
|
| 'error' // Error during cycle
|
||||||
| 'cancelled'; // Cancelled (e.g., controller stopped)
|
| 'cancelled'; // Cancelled (e.g., controller stopped)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Metrics for a single respawn cycle.
|
* Metrics for a single respawn cycle.
|
||||||
@@ -687,7 +679,13 @@ export const ErrorMessages: Record<ApiErrorCode, string> = {
|
|||||||
/**
|
/**
|
||||||
* Hook event types triggered by Claude Code's hooks system
|
* Hook event types triggered by Claude Code's hooks system
|
||||||
*/
|
*/
|
||||||
export type HookEventType = 'idle_prompt' | 'permission_prompt' | 'elicitation_dialog' | 'stop' | 'teammate_idle' | 'task_completed';
|
export type HookEventType =
|
||||||
|
| 'idle_prompt'
|
||||||
|
| 'permission_prompt'
|
||||||
|
| 'elicitation_dialog'
|
||||||
|
| 'stop'
|
||||||
|
| 'teammate_idle'
|
||||||
|
| 'task_completed';
|
||||||
|
|
||||||
// ========== API Response Types ==========
|
// ========== API Response Types ==========
|
||||||
|
|
||||||
@@ -842,12 +840,12 @@ export const DEFAULT_CONFIG: AppConfig = {
|
|||||||
maxConcurrentSessions: 5,
|
maxConcurrentSessions: 5,
|
||||||
stateFilePath: '',
|
stateFilePath: '',
|
||||||
respawn: {
|
respawn: {
|
||||||
idleTimeoutMs: 5000, // 5 seconds of no activity after prompt
|
idleTimeoutMs: 5000, // 5 seconds of no activity after prompt
|
||||||
updatePrompt: 'update all the docs and CLAUDE.md',
|
updatePrompt: 'update all the docs and CLAUDE.md',
|
||||||
interStepDelayMs: 1000, // 1 second between steps
|
interStepDelayMs: 1000, // 1 second between steps
|
||||||
enabled: false, // disabled by default
|
enabled: false, // disabled by default
|
||||||
sendClear: true, // send /clear after update prompt
|
sendClear: true, // send /clear after update prompt
|
||||||
sendInit: true, // send /init after /clear
|
sendInit: true, // send /init after /clear
|
||||||
},
|
},
|
||||||
lastUsedCase: null,
|
lastUsedCase: null,
|
||||||
ralphEnabled: false,
|
ralphEnabled: false,
|
||||||
@@ -1117,7 +1115,7 @@ export function createInitialCircuitBreakerStatus(): CircuitBreakerStatus {
|
|||||||
*/
|
*/
|
||||||
export function createInitialRalphTrackerState(): RalphTrackerState {
|
export function createInitialRalphTrackerState(): RalphTrackerState {
|
||||||
return {
|
return {
|
||||||
enabled: false, // Disabled by default, auto-enables when Ralph patterns detected
|
enabled: false, // Disabled by default, auto-enables when Ralph patterns detected
|
||||||
active: false,
|
active: false,
|
||||||
completionPhrase: null,
|
completionPhrase: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export class BufferAccumulator {
|
|||||||
* @returns True if buffer ends with the suffix
|
* @returns True if buffer ends with the suffix
|
||||||
*/
|
*/
|
||||||
endsWith(suffix: string): boolean {
|
endsWith(suffix: string): boolean {
|
||||||
if (!suffix) return true; // All strings end with empty string
|
if (!suffix) return true; // All strings end with empty string
|
||||||
if (suffix.length > this.totalLength) return false;
|
if (suffix.length > this.totalLength) return false;
|
||||||
return this.tail(suffix.length) === suffix;
|
return this.tail(suffix.length) === suffix;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function findClaudeDir(): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_claudeDir = ''; // mark as searched, not found
|
_claudeDir = ''; // mark as searched, not found
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,11 +115,7 @@ export class CleanupManager implements Disposable {
|
|||||||
* @param options - Optional configuration
|
* @param options - Optional configuration
|
||||||
* @returns Timer ID for manual clearing if needed
|
* @returns Timer ID for manual clearing if needed
|
||||||
*/
|
*/
|
||||||
setTimeout(
|
setTimeout(callback: () => void, delay: number, options?: TimerOptions): string {
|
||||||
callback: () => void,
|
|
||||||
delay: number,
|
|
||||||
options?: TimerOptions
|
|
||||||
): string {
|
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
// Remove registration when timer fires naturally
|
// Remove registration when timer fires naturally
|
||||||
@@ -148,11 +144,7 @@ export class CleanupManager implements Disposable {
|
|||||||
* @param options - Optional configuration
|
* @param options - Optional configuration
|
||||||
* @returns Interval ID for manual clearing if needed
|
* @returns Interval ID for manual clearing if needed
|
||||||
*/
|
*/
|
||||||
setInterval(
|
setInterval(callback: () => void, delay: number, options?: TimerOptions): string {
|
||||||
callback: () => void,
|
|
||||||
delay: number,
|
|
||||||
options?: TimerOptions
|
|
||||||
): string {
|
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
const intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
// Don't execute if stopped
|
// Don't execute if stopped
|
||||||
@@ -179,11 +171,7 @@ export class CleanupManager implements Disposable {
|
|||||||
* @param description - Human-readable description
|
* @param description - Human-readable description
|
||||||
* @returns Registration ID for manual removal if needed
|
* @returns Registration ID for manual removal if needed
|
||||||
*/
|
*/
|
||||||
registerCleanup(
|
registerCleanup(type: CleanupResourceType, cleanup: () => void, description: string): string {
|
||||||
type: CleanupResourceType,
|
|
||||||
cleanup: () => void,
|
|
||||||
description: string
|
|
||||||
): string {
|
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
this.register({
|
this.register({
|
||||||
id,
|
id,
|
||||||
@@ -202,10 +190,7 @@ export class CleanupManager implements Disposable {
|
|||||||
* @param description - Human-readable description
|
* @param description - Human-readable description
|
||||||
* @returns Registration ID
|
* @returns Registration ID
|
||||||
*/
|
*/
|
||||||
registerWatcher(
|
registerWatcher(watcher: { close: () => void }, description: string): string {
|
||||||
watcher: { close: () => void },
|
|
||||||
description: string
|
|
||||||
): string {
|
|
||||||
return this.registerCleanup('watcher', () => watcher.close(), description);
|
return this.registerCleanup('watcher', () => watcher.close(), description);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,19 +203,23 @@ export class CleanupManager implements Disposable {
|
|||||||
* @param description - Human-readable description
|
* @param description - Human-readable description
|
||||||
* @returns Registration ID
|
* @returns Registration ID
|
||||||
*/
|
*/
|
||||||
registerListener<T extends { removeListener?: (event: string, listener: () => void) => void; off?: (event: string, listener: () => void) => void }>(
|
registerListener<
|
||||||
emitter: T,
|
T extends {
|
||||||
event: string,
|
removeListener?: (event: string, listener: () => void) => void;
|
||||||
listener: () => void,
|
off?: (event: string, listener: () => void) => void;
|
||||||
description: string
|
},
|
||||||
): string {
|
>(emitter: T, event: string, listener: () => void, description: string): string {
|
||||||
return this.registerCleanup('listener', () => {
|
return this.registerCleanup(
|
||||||
if (emitter.removeListener) {
|
'listener',
|
||||||
emitter.removeListener(event, listener);
|
() => {
|
||||||
} else if (emitter.off) {
|
if (emitter.removeListener) {
|
||||||
emitter.off(event, listener);
|
emitter.removeListener(event, listener);
|
||||||
}
|
} else if (emitter.off) {
|
||||||
}, description);
|
emitter.off(event, listener);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -240,17 +229,18 @@ export class CleanupManager implements Disposable {
|
|||||||
* @param description - Human-readable description
|
* @param description - Human-readable description
|
||||||
* @returns Registration ID
|
* @returns Registration ID
|
||||||
*/
|
*/
|
||||||
registerStream(
|
registerStream(stream: { destroy?: () => void; close?: () => void }, description: string): string {
|
||||||
stream: { destroy?: () => void; close?: () => void },
|
return this.registerCleanup(
|
||||||
description: string
|
'stream',
|
||||||
): string {
|
() => {
|
||||||
return this.registerCleanup('stream', () => {
|
if (stream.destroy) {
|
||||||
if (stream.destroy) {
|
stream.destroy();
|
||||||
stream.destroy();
|
} else if (stream.close) {
|
||||||
} else if (stream.close) {
|
stream.close();
|
||||||
stream.close();
|
}
|
||||||
}
|
},
|
||||||
}, description);
|
description
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -297,8 +287,10 @@ export class CleanupManager implements Disposable {
|
|||||||
this.registrations.clear();
|
this.registrations.clear();
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
console.error(`[CleanupManager] ${errors.length} errors during disposal:`,
|
console.error(
|
||||||
errors.map(e => e.description).join(', '));
|
`[CleanupManager] ${errors.length} errors during disposal:`,
|
||||||
|
errors.map((e) => e.description).join(', ')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,17 +19,8 @@ export {
|
|||||||
createAnsiPatternSimple,
|
createAnsiPatternSimple,
|
||||||
stripAnsi,
|
stripAnsi,
|
||||||
} from './regex-patterns.js';
|
} from './regex-patterns.js';
|
||||||
export {
|
export { MAX_SESSION_TOKENS, validateTokenCounts, validateTokensAndCost } from './token-validation.js';
|
||||||
MAX_SESSION_TOKENS,
|
export { stringSimilarity, normalizePhrase, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
|
||||||
validateTokenCounts,
|
|
||||||
validateTokensAndCost,
|
|
||||||
} from './token-validation.js';
|
|
||||||
export {
|
|
||||||
stringSimilarity,
|
|
||||||
normalizePhrase,
|
|
||||||
fuzzyPhraseMatch,
|
|
||||||
todoContentHash,
|
|
||||||
} from './string-similarity.js';
|
|
||||||
export { assertNever } from './type-safety.js';
|
export { assertNever } from './type-safety.js';
|
||||||
export { wrapWithNice } from './nice-wrapper.js';
|
export { wrapWithNice } from './nice-wrapper.js';
|
||||||
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
|
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ const EXEC_TIMEOUT_MS = 5000;
|
|||||||
|
|
||||||
/** Common directories where the OpenCode CLI binary may be installed */
|
/** Common directories where the OpenCode CLI binary may be installed */
|
||||||
const OPENCODE_SEARCH_DIRS = [
|
const OPENCODE_SEARCH_DIRS = [
|
||||||
join(homedir(), '.opencode', 'bin'), // Default install location
|
join(homedir(), '.opencode', 'bin'), // Default install location
|
||||||
join(homedir(), '.local', 'bin'), // Alternative install location
|
join(homedir(), '.local', 'bin'), // Alternative install location
|
||||||
'/usr/local/bin', // Homebrew / system
|
'/usr/local/bin', // Homebrew / system
|
||||||
join(homedir(), 'go', 'bin'), // Go install
|
join(homedir(), 'go', 'bin'), // Go install
|
||||||
join(homedir(), '.bun', 'bin'), // Bun global
|
join(homedir(), '.bun', 'bin'), // Bun global
|
||||||
join(homedir(), '.npm-global', 'bin'), // npm global
|
join(homedir(), '.npm-global', 'bin'), // npm global
|
||||||
join(homedir(), 'bin'), // User bin
|
join(homedir(), 'bin'), // User bin
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Cached directory containing the opencode binary (empty string = searched but not found) */
|
/** Cached directory containing the opencode binary (empty string = searched but not found) */
|
||||||
@@ -41,7 +41,10 @@ export function resolveOpenCodeDir(): string | null {
|
|||||||
|
|
||||||
// Try `which` first (respects current PATH)
|
// Try `which` first (respects current PATH)
|
||||||
try {
|
try {
|
||||||
const result = execSync('which opencode', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim();
|
const result = execSync('which opencode', {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
}).trim();
|
||||||
if (result && existsSync(result)) {
|
if (result && existsSync(result)) {
|
||||||
_openCodeDir = dirname(result);
|
_openCodeDir = dirname(result);
|
||||||
return _openCodeDir;
|
return _openCodeDir;
|
||||||
@@ -58,7 +61,7 @@ export function resolveOpenCodeDir(): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_openCodeDir = ''; // mark as searched, not found
|
_openCodeDir = ''; // mark as searched, not found
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
* Use this when you need complete ANSI stripping including OSC sequences.
|
* Use this when you need complete ANSI stripping including OSC sequences.
|
||||||
* Note: Has global flag - reset lastIndex before exec() if reusing.
|
* Note: Has global flag - reset lastIndex before exec() if reusing.
|
||||||
*/
|
*/
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
export const ANSI_ESCAPE_PATTERN_FULL = /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g;
|
export const ANSI_ESCAPE_PATTERN_FULL = /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,6 +27,7 @@ export const ANSI_ESCAPE_PATTERN_FULL = /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]
|
|||||||
* Use this for faster stripping when OSC sequences aren't a concern.
|
* Use this for faster stripping when OSC sequences aren't a concern.
|
||||||
* Note: Has global flag - reset lastIndex before exec() if reusing.
|
* Note: Has global flag - reset lastIndex before exec() if reusing.
|
||||||
*/
|
*/
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
export const ANSI_ESCAPE_PATTERN_SIMPLE = /\x1b\[[0-9;]*[A-Za-z]/g;
|
export const ANSI_ESCAPE_PATTERN_SIMPLE = /\x1b\[[0-9;]*[A-Za-z]/g;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,6 +45,7 @@ export const TOKEN_PATTERN = /(\d+(?:\.\d+)?)\s*([kKmM])?\s*tokens/;
|
|||||||
* Use when you need a pattern without shared lastIndex state.
|
* Use when you need a pattern without shared lastIndex state.
|
||||||
*/
|
*/
|
||||||
export function createAnsiPatternFull(): RegExp {
|
export function createAnsiPatternFull(): RegExp {
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
return /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g;
|
return /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +54,7 @@ export function createAnsiPatternFull(): RegExp {
|
|||||||
* Use when you need a pattern without shared lastIndex state.
|
* Use when you need a pattern without shared lastIndex state.
|
||||||
*/
|
*/
|
||||||
export function createAnsiPatternSimple(): RegExp {
|
export function createAnsiPatternSimple(): RegExp {
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
return /\x1b\[[0-9;]*[A-Za-z]/g;
|
return /\x1b\[[0-9;]*[A-Za-z]/g;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,5 +78,4 @@ export function stripAnsi(text: string): string {
|
|||||||
*/
|
*/
|
||||||
export const SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧]/;
|
export const SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧]/;
|
||||||
|
|
||||||
export const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_\/\-. ~]+$/;
|
export const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_/\-. ~]+$/;
|
||||||
|
|
||||||
|
|||||||
@@ -245,29 +245,23 @@ export class StaleExpirationMap<K, V> implements Disposable {
|
|||||||
/**
|
/**
|
||||||
* Get all keys (non-expired).
|
* Get all keys (non-expired).
|
||||||
*/
|
*/
|
||||||
keys(): IterableIterator<K> {
|
*keys(): IterableIterator<K> {
|
||||||
const self = this;
|
for (const [key, entry] of this.entries) {
|
||||||
return (function* () {
|
if (!this.isExpired(entry)) {
|
||||||
for (const [key, entry] of self.entries) {
|
yield key;
|
||||||
if (!self.isExpired(entry)) {
|
|
||||||
yield key;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})();
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all values (non-expired).
|
* Get all values (non-expired).
|
||||||
*/
|
*/
|
||||||
values(): IterableIterator<V> {
|
*values(): IterableIterator<V> {
|
||||||
const self = this;
|
for (const [, entry] of this.entries) {
|
||||||
return (function* () {
|
if (!this.isExpired(entry)) {
|
||||||
for (const [, entry] of self.entries) {
|
yield entry.value;
|
||||||
if (!self.isExpired(entry)) {
|
|
||||||
yield entry.value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})();
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ export function levenshteinDistance(a: string, b: string): number {
|
|||||||
for (let i = 1; i <= m; i++) {
|
for (let i = 1; i <= m; i++) {
|
||||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||||
curr[i] = Math.min(
|
curr[i] = Math.min(
|
||||||
prev[i] + 1, // deletion
|
prev[i] + 1, // deletion
|
||||||
curr[i - 1] + 1, // insertion
|
curr[i - 1] + 1, // insertion
|
||||||
prev[i - 1] + cost // substitution
|
prev[i - 1] + cost // substitution
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -139,7 +139,7 @@ export function isSimilarByDistance(a: string, b: string, maxDistance = 2): bool
|
|||||||
export function normalizePhrase(phrase: string): string {
|
export function normalizePhrase(phrase: string): string {
|
||||||
return phrase
|
return phrase
|
||||||
.toUpperCase()
|
.toUpperCase()
|
||||||
.replace(/[\s_\-\.]+/g, '') // Remove whitespace, underscores, hyphens, dots
|
.replace(/[\s_\-.]+/g, '') // Remove whitespace, underscores, hyphens, dots
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,11 +161,7 @@ export function normalizePhrase(phrase: string): string {
|
|||||||
* fuzzyPhraseMatch('TASK_DONE', 'TASKDONE') // true (separator)
|
* fuzzyPhraseMatch('TASK_DONE', 'TASKDONE') // true (separator)
|
||||||
* fuzzyPhraseMatch('COMPLETE', 'FINISHED') // false (different word)
|
* fuzzyPhraseMatch('COMPLETE', 'FINISHED') // false (different word)
|
||||||
*/
|
*/
|
||||||
export function fuzzyPhraseMatch(
|
export function fuzzyPhraseMatch(phrase1: string, phrase2: string, maxDistance = 2): boolean {
|
||||||
phrase1: string,
|
|
||||||
phrase2: string,
|
|
||||||
maxDistance = 2
|
|
||||||
): boolean {
|
|
||||||
const norm1 = normalizePhrase(phrase1);
|
const norm1 = normalizePhrase(phrase1);
|
||||||
const norm2 = normalizePhrase(phrase2);
|
const norm2 = normalizePhrase(phrase2);
|
||||||
|
|
||||||
@@ -201,7 +197,7 @@ export function todoContentHash(content: string): string {
|
|||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < normalized.length; i++) {
|
for (let i = 0; i < normalized.length; i++) {
|
||||||
const char = normalized.charCodeAt(i);
|
const char = normalized.charCodeAt(i);
|
||||||
hash = ((hash << 5) - hash) + char;
|
hash = (hash << 5) - hash + char;
|
||||||
hash = hash & hash; // Convert to 32-bit integer
|
hash = hash & hash; // Convert to 32-bit integer
|
||||||
}
|
}
|
||||||
return hash.toString(36);
|
return hash.toString(36);
|
||||||
|
|||||||
@@ -21,10 +21,7 @@ export const MAX_SESSION_TOKENS = 500_000;
|
|||||||
* @param outputTokens - Output token count to validate
|
* @param outputTokens - Output token count to validate
|
||||||
* @returns Object with isValid flag and optional error reason
|
* @returns Object with isValid flag and optional error reason
|
||||||
*/
|
*/
|
||||||
export function validateTokenCounts(
|
export function validateTokenCounts(inputTokens: number, outputTokens: number): { isValid: boolean; reason?: string } {
|
||||||
inputTokens: number,
|
|
||||||
outputTokens: number
|
|
||||||
): { isValid: boolean; reason?: string } {
|
|
||||||
if (inputTokens < 0 || outputTokens < 0) {
|
if (inputTokens < 0 || outputTokens < 0) {
|
||||||
return {
|
return {
|
||||||
isValid: false,
|
isValid: false,
|
||||||
|
|||||||
@@ -15,11 +15,23 @@ import { SAFE_PATH_PATTERN } from '../utils/regex-patterns.js';
|
|||||||
/** Validate a path string: no shell metacharacters, no traversal, must be absolute */
|
/** Validate a path string: no shell metacharacters, no traversal, must be absolute */
|
||||||
export function isValidWorkingDir(p: string): boolean {
|
export function isValidWorkingDir(p: string): boolean {
|
||||||
if (!p || !p.startsWith('/')) return false;
|
if (!p || !p.startsWith('/')) return false;
|
||||||
if (p.includes(';') || p.includes('&') || p.includes('|') ||
|
if (
|
||||||
p.includes('$') || p.includes('`') || p.includes('(') ||
|
p.includes(';') ||
|
||||||
p.includes(')') || p.includes('{') || p.includes('}') ||
|
p.includes('&') ||
|
||||||
p.includes('<') || p.includes('>') || p.includes("'") ||
|
p.includes('|') ||
|
||||||
p.includes('"') || p.includes('\n') || p.includes('\r')) {
|
p.includes('$') ||
|
||||||
|
p.includes('`') ||
|
||||||
|
p.includes('(') ||
|
||||||
|
p.includes(')') ||
|
||||||
|
p.includes('{') ||
|
||||||
|
p.includes('}') ||
|
||||||
|
p.includes('<') ||
|
||||||
|
p.includes('>') ||
|
||||||
|
p.includes("'") ||
|
||||||
|
p.includes('"') ||
|
||||||
|
p.includes('\n') ||
|
||||||
|
p.includes('\r')
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (p.includes('..')) return false;
|
if (p.includes('..')) return false;
|
||||||
@@ -38,25 +50,35 @@ const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_'];
|
|||||||
|
|
||||||
/** Env var keys that are always blocked (security-sensitive) */
|
/** Env var keys that are always blocked (security-sensitive) */
|
||||||
const BLOCKED_ENV_KEYS = new Set([
|
const BLOCKED_ENV_KEYS = new Set([
|
||||||
'PATH', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'NODE_OPTIONS',
|
'PATH',
|
||||||
'CODEMAN_MUX_NAME', 'CODEMAN_TMUX',
|
'LD_PRELOAD',
|
||||||
'OPENCODE_SERVER_PASSWORD', // Security-sensitive: server auth password
|
'LD_LIBRARY_PATH',
|
||||||
|
'NODE_OPTIONS',
|
||||||
|
'CODEMAN_MUX_NAME',
|
||||||
|
'CODEMAN_TMUX',
|
||||||
|
'OPENCODE_SERVER_PASSWORD', // Security-sensitive: server auth password
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Validate that an env var key is allowed */
|
/** Validate that an env var key is allowed */
|
||||||
function isAllowedEnvKey(key: string): boolean {
|
function isAllowedEnvKey(key: string): boolean {
|
||||||
if (BLOCKED_ENV_KEYS.has(key)) return false;
|
if (BLOCKED_ENV_KEYS.has(key)) return false;
|
||||||
return ALLOWED_ENV_PREFIXES.some(prefix => key.startsWith(prefix));
|
return ALLOWED_ENV_PREFIXES.some((prefix) => key.startsWith(prefix));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Zod schema for env overrides with allowlist enforcement */
|
/** Zod schema for env overrides with allowlist enforcement */
|
||||||
const safeEnvOverridesSchema = z.record(z.string(), z.string()).optional().refine(
|
const safeEnvOverridesSchema = z
|
||||||
(val) => {
|
.record(z.string(), z.string())
|
||||||
if (!val) return true;
|
.optional()
|
||||||
return Object.keys(val).every(isAllowedEnvKey);
|
.refine(
|
||||||
},
|
(val) => {
|
||||||
{ message: 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_* and OPENCODE_* keys are allowed.' },
|
if (!val) return true;
|
||||||
);
|
return Object.keys(val).every(isAllowedEnvKey);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_* and OPENCODE_* keys are allowed.',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// ========== Session Routes ==========
|
// ========== Session Routes ==========
|
||||||
|
|
||||||
@@ -65,16 +87,37 @@ const safeEnvOverridesSchema = z.record(z.string(), z.string()).optional().refin
|
|||||||
* Creates a new session with optional working directory, mode, and name.
|
* Creates a new session with optional working directory, mode, and name.
|
||||||
*/
|
*/
|
||||||
/** Schema for OpenCode-specific configuration */
|
/** Schema for OpenCode-specific configuration */
|
||||||
const OpenCodeConfigSchema = z.object({
|
const OpenCodeConfigSchema = z
|
||||||
model: z.string().max(100).regex(/^[a-zA-Z0-9._\-/]+$/).optional(),
|
.object({
|
||||||
autoAllowTools: z.boolean().optional(),
|
model: z
|
||||||
continueSession: z.string().max(100).regex(/^[a-zA-Z0-9_-]+$/).optional(),
|
.string()
|
||||||
forkSession: z.boolean().optional(),
|
.max(100)
|
||||||
configContent: z.string().max(10000).refine(
|
.regex(/^[a-zA-Z0-9._\-/]+$/)
|
||||||
(val) => { try { JSON.parse(val); return true; } catch { return false; } },
|
.optional(),
|
||||||
{ message: 'configContent must be valid JSON' },
|
autoAllowTools: z.boolean().optional(),
|
||||||
).optional(),
|
continueSession: z
|
||||||
}).optional();
|
.string()
|
||||||
|
.max(100)
|
||||||
|
.regex(/^[a-zA-Z0-9_-]+$/)
|
||||||
|
.optional(),
|
||||||
|
forkSession: z.boolean().optional(),
|
||||||
|
configContent: z
|
||||||
|
.string()
|
||||||
|
.max(10000)
|
||||||
|
.refine(
|
||||||
|
(val) => {
|
||||||
|
try {
|
||||||
|
JSON.parse(val);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ message: 'configContent must be valid JSON' }
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
export const CreateSessionSchema = z.object({
|
export const CreateSessionSchema = z.object({
|
||||||
workingDir: safePathSchema.optional(),
|
workingDir: safePathSchema.optional(),
|
||||||
@@ -108,7 +151,9 @@ export const ResizeSchema = z.object({
|
|||||||
* Creates a new case folder.
|
* Creates a new case folder.
|
||||||
*/
|
*/
|
||||||
export const CreateCaseSchema = z.object({
|
export const CreateCaseSchema = z.object({
|
||||||
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.'),
|
name: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.'),
|
||||||
description: z.string().max(1000).optional(),
|
description: z.string().max(1000).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,7 +164,10 @@ export const CreateCaseSchema = z.object({
|
|||||||
* Creates case (if needed) and starts interactive session.
|
* Creates case (if needed) and starts interactive session.
|
||||||
*/
|
*/
|
||||||
export const QuickStartSchema = z.object({
|
export const QuickStartSchema = z.object({
|
||||||
caseName: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.').optional(),
|
caseName: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.')
|
||||||
|
.optional(),
|
||||||
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
|
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
|
||||||
openCodeConfig: OpenCodeConfigSchema,
|
openCodeConfig: OpenCodeConfigSchema,
|
||||||
});
|
});
|
||||||
@@ -175,86 +223,100 @@ export const RespawnConfigSchema = z.object({
|
|||||||
* Schema for PUT /api/config
|
* Schema for PUT /api/config
|
||||||
* Updates application configuration with whitelist of allowed fields.
|
* Updates application configuration with whitelist of allowed fields.
|
||||||
*/
|
*/
|
||||||
export const ConfigUpdateSchema = z.object({
|
export const ConfigUpdateSchema = z
|
||||||
pollIntervalMs: z.number().int().min(100).max(60000).optional(),
|
.object({
|
||||||
defaultTimeoutMs: z.number().int().min(1000).max(3600000).optional(),
|
pollIntervalMs: z.number().int().min(100).max(60000).optional(),
|
||||||
maxConcurrentSessions: z.number().int().min(1).max(50).optional(),
|
defaultTimeoutMs: z.number().int().min(1000).max(3600000).optional(),
|
||||||
respawn: RespawnConfigSchema.optional(),
|
maxConcurrentSessions: z.number().int().min(1).max(50).optional(),
|
||||||
}).strict();
|
respawn: RespawnConfigSchema.optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema for PUT /api/settings
|
* Schema for PUT /api/settings
|
||||||
* Explicit allowlist of known settings fields — prevents arbitrary key persistence.
|
* Explicit allowlist of known settings fields — prevents arbitrary key persistence.
|
||||||
*/
|
*/
|
||||||
const NotificationEventSchema = z.object({
|
const NotificationEventSchema = z
|
||||||
enabled: z.boolean().optional(),
|
.object({
|
||||||
browser: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
audio: z.boolean().optional(),
|
browser: z.boolean().optional(),
|
||||||
push: z.boolean().optional(),
|
audio: z.boolean().optional(),
|
||||||
}).optional();
|
push: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
export const SettingsUpdateSchema = z.object({
|
export const SettingsUpdateSchema = z
|
||||||
// Paths
|
.object({
|
||||||
defaultClaudeMdPath: z.string().max(500).optional(),
|
// Paths
|
||||||
defaultWorkingDir: z.string().max(500).optional(),
|
defaultClaudeMdPath: z.string().max(500).optional(),
|
||||||
lastUsedCase: z.string().max(200).optional(),
|
defaultWorkingDir: z.string().max(500).optional(),
|
||||||
// Feature toggles
|
lastUsedCase: z.string().max(200).optional(),
|
||||||
ralphTrackerEnabled: z.boolean().optional(),
|
// Feature toggles
|
||||||
subagentTrackingEnabled: z.boolean().optional(),
|
ralphTrackerEnabled: z.boolean().optional(),
|
||||||
subagentActiveTabOnly: z.boolean().optional(),
|
subagentTrackingEnabled: z.boolean().optional(),
|
||||||
imageWatcherEnabled: z.boolean().optional(),
|
subagentActiveTabOnly: z.boolean().optional(),
|
||||||
tunnelEnabled: z.boolean().optional(),
|
imageWatcherEnabled: z.boolean().optional(),
|
||||||
tabTwoRows: z.boolean().optional(),
|
tunnelEnabled: z.boolean().optional(),
|
||||||
agentTeamsEnabled: z.boolean().optional(),
|
tabTwoRows: z.boolean().optional(),
|
||||||
// UI visibility
|
agentTeamsEnabled: z.boolean().optional(),
|
||||||
showFontControls: z.boolean().optional(),
|
// UI visibility
|
||||||
showSystemStats: z.boolean().optional(),
|
showFontControls: z.boolean().optional(),
|
||||||
showTokenCount: z.boolean().optional(),
|
showSystemStats: z.boolean().optional(),
|
||||||
showCost: z.boolean().optional(),
|
showTokenCount: z.boolean().optional(),
|
||||||
showLifecycleLog: z.boolean().optional(),
|
showCost: z.boolean().optional(),
|
||||||
showMonitor: z.boolean().optional(),
|
showLifecycleLog: z.boolean().optional(),
|
||||||
showProjectInsights: z.boolean().optional(),
|
showMonitor: z.boolean().optional(),
|
||||||
showFileBrowser: z.boolean().optional(),
|
showProjectInsights: z.boolean().optional(),
|
||||||
showSubagents: z.boolean().optional(),
|
showFileBrowser: z.boolean().optional(),
|
||||||
// Claude CLI settings
|
showSubagents: z.boolean().optional(),
|
||||||
claudeMode: z.string().max(50).optional(),
|
// Claude CLI settings
|
||||||
allowedTools: z.string().max(2000).optional(),
|
claudeMode: z.string().max(50).optional(),
|
||||||
// CPU priority
|
allowedTools: z.string().max(2000).optional(),
|
||||||
nice: z.object({
|
// CPU priority
|
||||||
enabled: z.boolean().optional(),
|
nice: z
|
||||||
niceValue: z.number().int().min(-20).max(19).optional(),
|
.object({
|
||||||
}).optional(),
|
enabled: z.boolean().optional(),
|
||||||
// Notification preferences (cross-device sync)
|
niceValue: z.number().int().min(-20).max(19).optional(),
|
||||||
notificationPreferences: z.object({
|
})
|
||||||
enabled: z.boolean().optional(),
|
.optional(),
|
||||||
browserNotifications: z.boolean().optional(),
|
// Notification preferences (cross-device sync)
|
||||||
audioAlerts: z.boolean().optional(),
|
notificationPreferences: z
|
||||||
stuckThresholdMs: z.number().optional(),
|
.object({
|
||||||
muteCritical: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
muteWarning: z.boolean().optional(),
|
browserNotifications: z.boolean().optional(),
|
||||||
muteInfo: z.boolean().optional(),
|
audioAlerts: z.boolean().optional(),
|
||||||
eventTypes: z.object({
|
stuckThresholdMs: z.number().optional(),
|
||||||
permission_prompt: NotificationEventSchema,
|
muteCritical: z.boolean().optional(),
|
||||||
elicitation_dialog: NotificationEventSchema,
|
muteWarning: z.boolean().optional(),
|
||||||
idle_prompt: NotificationEventSchema,
|
muteInfo: z.boolean().optional(),
|
||||||
stop: NotificationEventSchema,
|
eventTypes: z
|
||||||
session_error: NotificationEventSchema,
|
.object({
|
||||||
respawn_cycle: NotificationEventSchema,
|
permission_prompt: NotificationEventSchema,
|
||||||
token_milestone: NotificationEventSchema,
|
elicitation_dialog: NotificationEventSchema,
|
||||||
ralph_complete: NotificationEventSchema,
|
idle_prompt: NotificationEventSchema,
|
||||||
subagent_spawn: NotificationEventSchema,
|
stop: NotificationEventSchema,
|
||||||
subagent_complete: NotificationEventSchema,
|
session_error: NotificationEventSchema,
|
||||||
}).optional(),
|
respawn_cycle: NotificationEventSchema,
|
||||||
_version: z.number().optional(),
|
token_milestone: NotificationEventSchema,
|
||||||
}).optional(),
|
ralph_complete: NotificationEventSchema,
|
||||||
// Voice settings (cross-device sync)
|
subagent_spawn: NotificationEventSchema,
|
||||||
voiceSettings: z.object({
|
subagent_complete: NotificationEventSchema,
|
||||||
apiKey: z.string().max(200).optional(),
|
})
|
||||||
language: z.string().max(20).optional(),
|
.optional(),
|
||||||
keyterms: z.string().max(500).optional(),
|
_version: z.number().optional(),
|
||||||
insertMode: z.string().max(20).optional(),
|
})
|
||||||
}).optional(),
|
.optional(),
|
||||||
}).strict();
|
// Voice settings (cross-device sync)
|
||||||
|
voiceSettings: z
|
||||||
|
.object({
|
||||||
|
apiKey: z.string().max(200).optional(),
|
||||||
|
language: z.string().max(20).optional(),
|
||||||
|
keyterms: z.string().max(500).optional(),
|
||||||
|
insertMode: z.string().max(20).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema for POST /api/sessions/:id/input with length limit
|
* Schema for POST /api/sessions/:id/input with length limit
|
||||||
@@ -381,10 +443,12 @@ export const CpuLimitSchema = z.object({
|
|||||||
export const ModelConfigUpdateSchema = z.record(z.string(), z.unknown());
|
export const ModelConfigUpdateSchema = z.record(z.string(), z.unknown());
|
||||||
|
|
||||||
/** PUT /api/subagent-window-states */
|
/** PUT /api/subagent-window-states */
|
||||||
export const SubagentWindowStatesSchema = z.object({
|
export const SubagentWindowStatesSchema = z
|
||||||
minimized: z.record(z.string(), z.boolean()).optional(),
|
.object({
|
||||||
open: z.array(z.string()).optional(),
|
minimized: z.record(z.string(), z.boolean()).optional(),
|
||||||
}).passthrough();
|
open: z.array(z.string()).optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
/** PUT /api/subagent-parents */
|
/** PUT /api/subagent-parents */
|
||||||
export const SubagentParentMapSchema = z.record(z.string(), z.string());
|
export const SubagentParentMapSchema = z.record(z.string(), z.string());
|
||||||
@@ -423,14 +487,22 @@ export const PushPreferencesUpdateSchema = z.object({
|
|||||||
|
|
||||||
/** POST /api/ralph-loop/start */
|
/** POST /api/ralph-loop/start */
|
||||||
export const RalphLoopStartSchema = z.object({
|
export const RalphLoopStartSchema = z.object({
|
||||||
caseName: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format').optional().default('testcase'),
|
caseName: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')
|
||||||
|
.optional()
|
||||||
|
.default('testcase'),
|
||||||
taskDescription: z.string().min(1).max(100000),
|
taskDescription: z.string().min(1).max(100000),
|
||||||
completionPhrase: z.string().max(100).default('COMPLETE'),
|
completionPhrase: z.string().max(100).default('COMPLETE'),
|
||||||
maxIterations: z.number().int().min(0).max(1000).nullable().default(10),
|
maxIterations: z.number().int().min(0).max(1000).nullable().default(10),
|
||||||
enableRespawn: z.boolean().default(false),
|
enableRespawn: z.boolean().default(false),
|
||||||
planItems: z.array(z.object({
|
planItems: z
|
||||||
content: z.string(),
|
.array(
|
||||||
priority: z.string().optional(),
|
z.object({
|
||||||
enabled: z.boolean().default(true),
|
content: z.string(),
|
||||||
})).optional(),
|
priority: z.string().optional(),
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
|
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 |