mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
chore: project setup and tooling cleanup (#26)
* add project setup * add tools to eslint * remove contributing.md * update claude md * chore: simplify CI to single Node.js version (22) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * use node 20 * adjust formatting * fix linting * format * fix layout * fix: align CI node version to .nvmrc (22), remove redundant gotcha Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: arkon <arkon.85@hotmail.com>
This commit is contained in:
61 files changed
+4306
-4013
No files matched your search
@@ -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/**',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
Generated
+1927
-2604
File diff suppressed because it is too large.
Load diff
+16
-5
@@ -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');
|
||||||
+12
-16
@@ -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 ==========
|
||||||
|
|
||||||
|
|||||||
+14
-17
@@ -73,25 +73,25 @@ const FOLLOW_MODE_PATTERN = /\s-[A-Za-z]*f[A-Za-z]*\s|\s--follow\s/;
|
|||||||
* Note: This is a simpler approach - we run it on each command string
|
* Note: This is a simpler approach - we run it on each command string
|
||||||
* rather than trying to match globally.
|
* rather than trying to match globally.
|
||||||
*/
|
*/
|
||||||
const FILE_PATH_PATTERN = /(?:^|\s|['"]|=)([\/~][^\s'"<>|;&\n]+)/g;
|
const FILE_PATH_PATTERN = /(?:^|\s|['"]|=)([/~][^\s'"<>|;&\n]+)/g;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pattern to detect paths that are likely not real files (flags, etc.)
|
* Pattern to detect paths that are likely not real files (flags, etc.)
|
||||||
*/
|
*/
|
||||||
const INVALID_PATH_PATTERN = /^[\/~]-|\/dev\/null$/;
|
const INVALID_PATH_PATTERN = /^[/~]-|\/dev\/null$/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pattern to detect command suggestions in plain text output.
|
* Pattern to detect command suggestions in plain text output.
|
||||||
* Matches lines like "tail -f /path/to/file" without the ● Bash() wrapper.
|
* Matches lines like "tail -f /path/to/file" without the ● Bash() wrapper.
|
||||||
* This catches commands Claude mentions but doesn't execute.
|
* This catches commands Claude mentions but doesn't execute.
|
||||||
*/
|
*/
|
||||||
const TEXT_COMMAND_PATTERN = /^\s*(tail|cat|head|less|grep|watch|multitail)\s+(?:-[^\s]+\s+)*([\/~][^\s'"<>|;&\n]+)/;
|
const TEXT_COMMAND_PATTERN = /^\s*(tail|cat|head|less|grep|watch|multitail)\s+(?:-[^\s]+\s+)*([/~][^\s'"<>|;&\n]+)/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pattern to detect log file paths mentioned in text (even without commands).
|
* Pattern to detect log file paths mentioned in text (even without commands).
|
||||||
* Matches paths ending in .log, .txt, .out, or in common log directories.
|
* Matches paths ending in .log, .txt, .out, or in common log directories.
|
||||||
*/
|
*/
|
||||||
const LOG_FILE_MENTION_PATTERN = /([\/~][^\s'"<>|;&\n]*(?:\.log|\.txt|\.out|\/log\/[^\s'"<>|;&\n]+))/g;
|
const LOG_FILE_MENTION_PATTERN = /([/~][^\s'"<>|;&\n]*(?:\.log|\.txt|\.out|\/log\/[^\s'"<>|;&\n]+))/g;
|
||||||
|
|
||||||
// ========== Event Interfaces ==========
|
// ========== Event Interfaces ==========
|
||||||
|
|
||||||
@@ -245,7 +245,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
|||||||
*/
|
*/
|
||||||
private isShallowRootPath(path: string): boolean {
|
private isShallowRootPath(path: string): boolean {
|
||||||
if (!path.startsWith('/')) return false;
|
if (!path.startsWith('/')) return false;
|
||||||
const parts = path.split('/').filter(p => p !== '');
|
const parts = path.split('/').filter((p) => p !== '');
|
||||||
return parts.length === 1;
|
return parts.length === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,10 +354,10 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
|||||||
isFilePathTracked(filePath: string): boolean {
|
isFilePathTracked(filePath: string): boolean {
|
||||||
const normalizedNew = this.normalizePath(filePath);
|
const normalizedNew = this.normalizePath(filePath);
|
||||||
|
|
||||||
return Array.from(this._activeTools.values()).some(t => {
|
return Array.from(this._activeTools.values()).some((t) => {
|
||||||
if (t.status !== 'running') return false;
|
if (t.status !== 'running') return false;
|
||||||
|
|
||||||
return t.filePaths.some(existingPath => {
|
return t.filePaths.some((existingPath) => {
|
||||||
const normalizedExisting = this.normalizePath(existingPath);
|
const normalizedExisting = this.normalizePath(existingPath);
|
||||||
return normalizedExisting === normalizedNew;
|
return normalizedExisting === normalizedNew;
|
||||||
});
|
});
|
||||||
@@ -418,9 +418,8 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
|||||||
// Prevent unbounded growth
|
// Prevent unbounded growth
|
||||||
if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) {
|
if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) {
|
||||||
const trimPoint = this._lineBuffer.lastIndexOf('\n', MAX_LINE_BUFFER_SIZE / 2);
|
const trimPoint = this._lineBuffer.lastIndexOf('\n', MAX_LINE_BUFFER_SIZE / 2);
|
||||||
this._lineBuffer = trimPoint > 0
|
this._lineBuffer =
|
||||||
? this._lineBuffer.slice(trimPoint + 1)
|
trimPoint > 0 ? this._lineBuffer.slice(trimPoint + 1) : this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
|
||||||
: this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process complete lines
|
// Process complete lines
|
||||||
@@ -445,9 +444,8 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
|||||||
|
|
||||||
if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) {
|
if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) {
|
||||||
const trimPoint = this._lineBuffer.lastIndexOf('\n', MAX_LINE_BUFFER_SIZE / 2);
|
const trimPoint = this._lineBuffer.lastIndexOf('\n', MAX_LINE_BUFFER_SIZE / 2);
|
||||||
this._lineBuffer = trimPoint > 0
|
this._lineBuffer =
|
||||||
? this._lineBuffer.slice(trimPoint + 1)
|
trimPoint > 0 ? this._lineBuffer.slice(trimPoint + 1) : this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
|
||||||
: this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = this._lineBuffer.split('\n');
|
const lines = this._lineBuffer.split('\n');
|
||||||
@@ -472,7 +470,6 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
|||||||
* Process a single pre-stripped line of terminal output.
|
* Process a single pre-stripped line of terminal output.
|
||||||
*/
|
*/
|
||||||
private processCleanLine(cleanLine: string): void {
|
private processCleanLine(cleanLine: string): void {
|
||||||
|
|
||||||
// Check for tool start
|
// Check for tool start
|
||||||
const startMatch = cleanLine.match(BASH_TOOL_START_PATTERN);
|
const startMatch = cleanLine.match(BASH_TOOL_START_PATTERN);
|
||||||
if (startMatch) {
|
if (startMatch) {
|
||||||
@@ -484,7 +481,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
|||||||
const filePaths = this.extractFilePaths(command);
|
const filePaths = this.extractFilePaths(command);
|
||||||
|
|
||||||
// Skip if any file path is already tracked (cross-pattern dedup)
|
// Skip if any file path is already tracked (cross-pattern dedup)
|
||||||
if (filePaths.some(fp => this.isFilePathTracked(fp))) {
|
if (filePaths.some((fp) => this.isFilePathTracked(fp))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,8 +499,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
|||||||
// Enforce max tools limit
|
// Enforce max tools limit
|
||||||
if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
|
if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
|
||||||
// Remove oldest tool
|
// Remove oldest tool
|
||||||
const oldest = Array.from(this._activeTools.entries())
|
const oldest = Array.from(this._activeTools.entries()).sort((a, b) => a[1].startedAt - b[1].startedAt)[0];
|
||||||
.sort((a, b) => a[1].startedAt - b[1].startedAt)[0];
|
|
||||||
if (oldest) {
|
if (oldest) {
|
||||||
this._activeTools.delete(oldest[0]);
|
this._activeTools.delete(oldest[0]);
|
||||||
}
|
}
|
||||||
@@ -671,6 +667,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
|||||||
*/
|
*/
|
||||||
private stripAnsi(str: string): string {
|
private stripAnsi(str: string): string {
|
||||||
// Comprehensive ANSI pattern
|
// Comprehensive ANSI pattern
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
return str.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g, '');
|
return str.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+31
-44
@@ -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.
|
||||||
|
|||||||
+7
-10
@@ -24,15 +24,7 @@ export interface ImageWatcherEvents {
|
|||||||
// ========== Constants ==========
|
// ========== Constants ==========
|
||||||
|
|
||||||
/** Supported image file extensions (lowercase) */
|
/** Supported image file extensions (lowercase) */
|
||||||
const IMAGE_EXTENSIONS = new Set([
|
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
|
||||||
'.png',
|
|
||||||
'.jpg',
|
|
||||||
'.jpeg',
|
|
||||||
'.gif',
|
|
||||||
'.webp',
|
|
||||||
'.bmp',
|
|
||||||
'.svg',
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** Time to wait for file writes to stabilize (ms) */
|
/** Time to wait for file writes to stabilize (ms) */
|
||||||
const STABILITY_THRESHOLD_MS = 500;
|
const STABILITY_THRESHOLD_MS = 500;
|
||||||
@@ -171,7 +163,12 @@ export class ImageWatcher extends EventEmitter {
|
|||||||
// Ignore common heavy directories for performance
|
// Ignore common heavy directories for performance
|
||||||
ignored: (path: string) => {
|
ignored: (path: string) => {
|
||||||
// Skip node_modules, .git, and other heavy directories
|
// Skip node_modules, .git, and other heavy directories
|
||||||
if (path.includes('/node_modules/') || path.includes('/.git/') || path.includes('/dist/') || path.includes('/.next/')) {
|
if (
|
||||||
|
path.includes('/node_modules/') ||
|
||||||
|
path.includes('/.git/') ||
|
||||||
|
path.includes('/dist/') ||
|
||||||
|
path.includes('/.next/')
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const ext = extname(path).toLowerCase();
|
const ext = extname(path).toLowerCase();
|
||||||
|
|||||||
+3
-1
@@ -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.
|
||||||
|
|||||||
+178
-30
@@ -19,10 +19,7 @@ import { Session } from './session.js';
|
|||||||
import type { TerminalMultiplexer } from './mux-interface.js';
|
import type { TerminalMultiplexer } from './mux-interface.js';
|
||||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import {
|
import { RESEARCH_AGENT_PROMPT, PLANNER_PROMPT } from './prompts/index.js';
|
||||||
RESEARCH_AGENT_PROMPT,
|
|
||||||
PLANNER_PROMPT,
|
|
||||||
} from './prompts/index.js';
|
|
||||||
import { PlanTaskStatus, TddPhase } from './types.js';
|
import { PlanTaskStatus, TddPhase } from './types.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -170,7 +167,7 @@ export class PlanOrchestrator {
|
|||||||
mux: TerminalMultiplexer,
|
mux: TerminalMultiplexer,
|
||||||
workingDir: string = process.cwd(),
|
workingDir: string = process.cwd(),
|
||||||
outputDir?: string,
|
outputDir?: string,
|
||||||
modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record<string, string> },
|
modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record<string, string> }
|
||||||
) {
|
) {
|
||||||
this.mux = mux;
|
this.mux = mux;
|
||||||
this.workingDir = workingDir;
|
this.workingDir = workingDir;
|
||||||
@@ -193,7 +190,11 @@ export class PlanOrchestrator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const promptPath = join(agentDir, 'prompt.md');
|
const promptPath = join(agentDir, 'prompt.md');
|
||||||
writeFileSync(promptPath, `# ${agentType} Agent Prompt\n\nGenerated: ${new Date().toISOString()}\nDuration: ${(durationMs / 1000).toFixed(1)}s\n\n## Task\n${this.taskDescription}\n\n## Prompt\n${prompt}\n`, 'utf-8');
|
writeFileSync(
|
||||||
|
promptPath,
|
||||||
|
`# ${agentType} Agent Prompt\n\nGenerated: ${new Date().toISOString()}\nDuration: ${(durationMs / 1000).toFixed(1)}s\n\n## Task\n${this.taskDescription}\n\n## Prompt\n${prompt}\n`,
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
const resultPath = join(agentDir, 'result.json');
|
const resultPath = join(agentDir, 'result.json');
|
||||||
writeFileSync(resultPath, JSON.stringify(result, null, 2), 'utf-8');
|
writeFileSync(resultPath, JSON.stringify(result, null, 2), 'utf-8');
|
||||||
@@ -227,9 +228,9 @@ export class PlanOrchestrator {
|
|||||||
|
|
||||||
private generateSummary(result: DetailedPlanResult): string {
|
private generateSummary(result: DetailedPlanResult): string {
|
||||||
const items = result.items || [];
|
const items = result.items || [];
|
||||||
const p0 = items.filter(i => i.priority === 'P0');
|
const p0 = items.filter((i) => i.priority === 'P0');
|
||||||
const p1 = items.filter(i => i.priority === 'P1');
|
const p1 = items.filter((i) => i.priority === 'P1');
|
||||||
const p2 = items.filter(i => i.priority === 'P2');
|
const p2 = items.filter((i) => i.priority === 'P2');
|
||||||
|
|
||||||
let md = `# Plan Summary\n\n`;
|
let md = `# Plan Summary\n\n`;
|
||||||
md += `Generated: ${new Date().toISOString()}\n`;
|
md += `Generated: ${new Date().toISOString()}\n`;
|
||||||
@@ -392,10 +393,29 @@ export class PlanOrchestrator {
|
|||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
if (this.cancelled) {
|
if (this.cancelled) {
|
||||||
return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error: 'Cancelled', durationMs: 0 };
|
return {
|
||||||
|
success: false,
|
||||||
|
findings: {
|
||||||
|
externalResources: [],
|
||||||
|
codebasePatterns: [],
|
||||||
|
technicalRecommendations: [],
|
||||||
|
potentialChallenges: [],
|
||||||
|
recommendedTools: [],
|
||||||
|
},
|
||||||
|
enrichedTaskDescription: taskDescription,
|
||||||
|
error: 'Cancelled',
|
||||||
|
durationMs: 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubagent?.({ type: 'started', agentId, agentType: 'research', model: this.researchModel, status: 'running', detail: 'Researching...' });
|
onSubagent?.({
|
||||||
|
type: 'started',
|
||||||
|
agentId,
|
||||||
|
agentType: 'research',
|
||||||
|
model: this.researchModel,
|
||||||
|
status: 'running',
|
||||||
|
detail: 'Researching...',
|
||||||
|
});
|
||||||
|
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
workingDir: this.workingDir,
|
workingDir: this.workingDir,
|
||||||
@@ -411,7 +431,14 @@ export class PlanOrchestrator {
|
|||||||
// Start progress interval before try block to ensure cleanup in finally
|
// Start progress interval before try block to ensure cleanup in finally
|
||||||
const progressInterval = setInterval(() => {
|
const progressInterval = setInterval(() => {
|
||||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||||
onSubagent?.({ type: 'progress', agentId, agentType: 'research', model: this.researchModel, status: 'running', detail: `${elapsed}s elapsed` });
|
onSubagent?.({
|
||||||
|
type: 'progress',
|
||||||
|
agentId,
|
||||||
|
agentType: 'research',
|
||||||
|
model: this.researchModel,
|
||||||
|
status: 'running',
|
||||||
|
detail: `${elapsed}s elapsed`,
|
||||||
|
});
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -424,14 +451,54 @@ export class PlanOrchestrator {
|
|||||||
// Extract JSON from response
|
// Extract JSON from response
|
||||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||||
if (!jsonMatch) {
|
if (!jsonMatch) {
|
||||||
onSubagent?.({ type: 'failed', agentId, agentType: 'research', model: this.researchModel, status: 'failed', error: 'No JSON found', durationMs });
|
onSubagent?.({
|
||||||
return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error: 'No JSON in response', durationMs };
|
type: 'failed',
|
||||||
|
agentId,
|
||||||
|
agentType: 'research',
|
||||||
|
model: this.researchModel,
|
||||||
|
status: 'failed',
|
||||||
|
error: 'No JSON found',
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
findings: {
|
||||||
|
externalResources: [],
|
||||||
|
codebasePatterns: [],
|
||||||
|
technicalRecommendations: [],
|
||||||
|
potentialChallenges: [],
|
||||||
|
recommendedTools: [],
|
||||||
|
},
|
||||||
|
enrichedTaskDescription: taskDescription,
|
||||||
|
error: 'No JSON in response',
|
||||||
|
durationMs,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = tryParseJSON(jsonMatch[0]);
|
const parsed = tryParseJSON(jsonMatch[0]);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
onSubagent?.({ type: 'failed', agentId, agentType: 'research', model: this.researchModel, status: 'failed', error: parsed.error, durationMs });
|
onSubagent?.({
|
||||||
return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error: parsed.error, durationMs };
|
type: 'failed',
|
||||||
|
agentId,
|
||||||
|
agentType: 'research',
|
||||||
|
model: this.researchModel,
|
||||||
|
status: 'failed',
|
||||||
|
error: parsed.error,
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
findings: {
|
||||||
|
externalResources: [],
|
||||||
|
codebasePatterns: [],
|
||||||
|
technicalRecommendations: [],
|
||||||
|
potentialChallenges: [],
|
||||||
|
recommendedTools: [],
|
||||||
|
},
|
||||||
|
enrichedTaskDescription: taskDescription,
|
||||||
|
error: parsed.error,
|
||||||
|
durationMs,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = parsed.data as Record<string, unknown>;
|
const data = parsed.data as Record<string, unknown>;
|
||||||
@@ -444,20 +511,48 @@ export class PlanOrchestrator {
|
|||||||
potentialChallenges: Array.isArray(data.potentialChallenges) ? data.potentialChallenges : [],
|
potentialChallenges: Array.isArray(data.potentialChallenges) ? data.potentialChallenges : [],
|
||||||
recommendedTools: Array.isArray(data.recommendedTools) ? data.recommendedTools : [],
|
recommendedTools: Array.isArray(data.recommendedTools) ? data.recommendedTools : [],
|
||||||
},
|
},
|
||||||
enrichedTaskDescription: typeof data.enrichedTaskDescription === 'string' ? data.enrichedTaskDescription : taskDescription,
|
enrichedTaskDescription:
|
||||||
|
typeof data.enrichedTaskDescription === 'string' ? data.enrichedTaskDescription : taskDescription,
|
||||||
durationMs,
|
durationMs,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.saveAgentOutput('research', prompt, result, durationMs);
|
this.saveAgentOutput('research', prompt, result, durationMs);
|
||||||
onSubagent?.({ type: 'completed', agentId, agentType: 'research', model: this.researchModel, status: 'completed', durationMs });
|
onSubagent?.({
|
||||||
|
type: 'completed',
|
||||||
|
agentId,
|
||||||
|
agentType: 'research',
|
||||||
|
model: this.researchModel,
|
||||||
|
status: 'completed',
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.runningSessions.delete(session);
|
this.runningSessions.delete(session);
|
||||||
const durationMs = Date.now() - startTime;
|
const durationMs = Date.now() - startTime;
|
||||||
const error = err instanceof Error ? err.message : String(err);
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
onSubagent?.({ type: 'failed', agentId, agentType: 'research', model: this.researchModel, status: 'failed', error, durationMs });
|
onSubagent?.({
|
||||||
return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error, durationMs };
|
type: 'failed',
|
||||||
|
agentId,
|
||||||
|
agentType: 'research',
|
||||||
|
model: this.researchModel,
|
||||||
|
status: 'failed',
|
||||||
|
error,
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
findings: {
|
||||||
|
externalResources: [],
|
||||||
|
codebasePatterns: [],
|
||||||
|
technicalRecommendations: [],
|
||||||
|
potentialChallenges: [],
|
||||||
|
recommendedTools: [],
|
||||||
|
},
|
||||||
|
enrichedTaskDescription: taskDescription,
|
||||||
|
error,
|
||||||
|
durationMs,
|
||||||
|
};
|
||||||
} finally {
|
} finally {
|
||||||
// Always clear the progress interval to prevent memory leaks
|
// Always clear the progress interval to prevent memory leaks
|
||||||
clearInterval(progressInterval);
|
clearInterval(progressInterval);
|
||||||
@@ -469,7 +564,13 @@ export class PlanOrchestrator {
|
|||||||
researchContext: string,
|
researchContext: string,
|
||||||
onProgress?: ProgressCallback,
|
onProgress?: ProgressCallback,
|
||||||
onSubagent?: SubagentCallback
|
onSubagent?: SubagentCallback
|
||||||
): Promise<{ success: boolean; items?: PlanItem[]; gaps?: string[]; warnings?: string[]; error?: string }> {
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
items?: PlanItem[];
|
||||||
|
gaps?: string[];
|
||||||
|
warnings?: string[];
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
const agentId = `planner-${Date.now()}`;
|
const agentId = `planner-${Date.now()}`;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
@@ -477,7 +578,14 @@ export class PlanOrchestrator {
|
|||||||
return { success: false, error: 'Cancelled' };
|
return { success: false, error: 'Cancelled' };
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubagent?.({ type: 'started', agentId, agentType: 'planner', model: this.plannerModel, status: 'running', detail: 'Generating plan...' });
|
onSubagent?.({
|
||||||
|
type: 'started',
|
||||||
|
agentId,
|
||||||
|
agentType: 'planner',
|
||||||
|
model: this.plannerModel,
|
||||||
|
status: 'running',
|
||||||
|
detail: 'Generating plan...',
|
||||||
|
});
|
||||||
|
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
workingDir: this.workingDir,
|
workingDir: this.workingDir,
|
||||||
@@ -488,14 +596,22 @@ export class PlanOrchestrator {
|
|||||||
|
|
||||||
this.runningSessions.add(session);
|
this.runningSessions.add(session);
|
||||||
|
|
||||||
const prompt = PLANNER_PROMPT
|
const prompt = PLANNER_PROMPT.replace('{TASK}', taskDescription).replace(
|
||||||
.replace('{TASK}', taskDescription)
|
'{RESEARCH_CONTEXT}',
|
||||||
.replace('{RESEARCH_CONTEXT}', researchContext || '');
|
researchContext || ''
|
||||||
|
);
|
||||||
|
|
||||||
// Start progress interval before try block to ensure cleanup in finally
|
// Start progress interval before try block to ensure cleanup in finally
|
||||||
const progressInterval = setInterval(() => {
|
const progressInterval = setInterval(() => {
|
||||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||||
onSubagent?.({ type: 'progress', agentId, agentType: 'planner', model: this.plannerModel, status: 'running', detail: `${elapsed}s elapsed` });
|
onSubagent?.({
|
||||||
|
type: 'progress',
|
||||||
|
agentId,
|
||||||
|
agentType: 'planner',
|
||||||
|
model: this.plannerModel,
|
||||||
|
status: 'running',
|
||||||
|
detail: `${elapsed}s elapsed`,
|
||||||
|
});
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -508,13 +624,29 @@ export class PlanOrchestrator {
|
|||||||
// Extract JSON from response
|
// Extract JSON from response
|
||||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||||
if (!jsonMatch) {
|
if (!jsonMatch) {
|
||||||
onSubagent?.({ type: 'failed', agentId, agentType: 'planner', model: this.plannerModel, status: 'failed', error: 'No JSON found', durationMs });
|
onSubagent?.({
|
||||||
|
type: 'failed',
|
||||||
|
agentId,
|
||||||
|
agentType: 'planner',
|
||||||
|
model: this.plannerModel,
|
||||||
|
status: 'failed',
|
||||||
|
error: 'No JSON found',
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
return { success: false, error: 'No JSON in response' };
|
return { success: false, error: 'No JSON in response' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = tryParseJSON(jsonMatch[0]);
|
const parsed = tryParseJSON(jsonMatch[0]);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
onSubagent?.({ type: 'failed', agentId, agentType: 'planner', model: this.plannerModel, status: 'failed', error: parsed.error, durationMs });
|
onSubagent?.({
|
||||||
|
type: 'failed',
|
||||||
|
agentId,
|
||||||
|
agentType: 'planner',
|
||||||
|
model: this.plannerModel,
|
||||||
|
status: 'failed',
|
||||||
|
error: parsed.error,
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
return { success: false, error: parsed.error };
|
return { success: false, error: parsed.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,7 +656,15 @@ export class PlanOrchestrator {
|
|||||||
const warnings: string[] = Array.isArray(data.warnings) ? data.warnings : [];
|
const warnings: string[] = Array.isArray(data.warnings) ? data.warnings : [];
|
||||||
|
|
||||||
this.saveAgentOutput('planner', prompt, { items, gaps, warnings }, durationMs);
|
this.saveAgentOutput('planner', prompt, { items, gaps, warnings }, durationMs);
|
||||||
onSubagent?.({ type: 'completed', agentId, agentType: 'planner', model: this.plannerModel, status: 'completed', itemCount: items.length, durationMs });
|
onSubagent?.({
|
||||||
|
type: 'completed',
|
||||||
|
agentId,
|
||||||
|
agentType: 'planner',
|
||||||
|
model: this.plannerModel,
|
||||||
|
status: 'completed',
|
||||||
|
itemCount: items.length,
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
|
||||||
onProgress?.('planning', `Generated ${items.length} tasks`);
|
onProgress?.('planning', `Generated ${items.length} tasks`);
|
||||||
|
|
||||||
@@ -533,7 +673,15 @@ export class PlanOrchestrator {
|
|||||||
this.runningSessions.delete(session);
|
this.runningSessions.delete(session);
|
||||||
const durationMs = Date.now() - startTime;
|
const durationMs = Date.now() - startTime;
|
||||||
const error = err instanceof Error ? err.message : String(err);
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
onSubagent?.({ type: 'failed', agentId, agentType: 'planner', model: this.plannerModel, status: 'failed', error, durationMs });
|
onSubagent?.({
|
||||||
|
type: 'failed',
|
||||||
|
agentId,
|
||||||
|
agentType: 'planner',
|
||||||
|
model: this.plannerModel,
|
||||||
|
status: 'failed',
|
||||||
|
error,
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
return { success: false, error };
|
return { success: false, error };
|
||||||
} finally {
|
} finally {
|
||||||
// Always clear the progress interval to prevent memory leaks
|
// Always clear the progress interval to prevent memory leaks
|
||||||
|
|||||||
+2
-9
@@ -282,10 +282,7 @@ export class RalphLoop extends EventEmitter {
|
|||||||
this.store.setRalphLoopState({ lastCheckAt: Date.now() });
|
this.store.setRalphLoopState({ lastCheckAt: Date.now() });
|
||||||
|
|
||||||
// Run independent checks in parallel for better performance
|
// Run independent checks in parallel for better performance
|
||||||
await Promise.all([
|
await Promise.all([this.checkTimeouts(), this.assignTasks()]);
|
||||||
this.checkTimeouts(),
|
|
||||||
this.assignTasks(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Check if we should auto-generate tasks (depends on assignment results)
|
// Check if we should auto-generate tasks (depends on assignment results)
|
||||||
if (this.autoGenerateTasks && this.shouldGenerateTasks()) {
|
if (this.autoGenerateTasks && this.shouldGenerateTasks()) {
|
||||||
@@ -409,11 +406,7 @@ export class RalphLoop extends EventEmitter {
|
|||||||
// 2. Min duration not reached
|
// 2. Min duration not reached
|
||||||
// 3. We have idle sessions
|
// 3. We have idle sessions
|
||||||
const counts = this.taskQueue.getCount();
|
const counts = this.taskQueue.getCount();
|
||||||
return (
|
return counts.pending === 0 && !this.isMinDurationReached() && this.sessionManager.getIdleSessions().length > 0;
|
||||||
counts.pending === 0 &&
|
|
||||||
!this.isMinDurationReached() &&
|
|
||||||
this.sessionManager.getIdleSessions().length > 0
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generateFollowUpTasks(): Promise<void> {
|
private async generateFollowUpTasks(): Promise<void> {
|
||||||
|
|||||||
+179
-154
@@ -34,12 +34,7 @@ import {
|
|||||||
PlanTaskStatus,
|
PlanTaskStatus,
|
||||||
TddPhase,
|
TddPhase,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import {
|
import { ANSI_ESCAPE_PATTERN_SIMPLE, fuzzyPhraseMatch, todoContentHash, stringSimilarity } from './utils/index.js';
|
||||||
ANSI_ESCAPE_PATTERN_SIMPLE,
|
|
||||||
fuzzyPhraseMatch,
|
|
||||||
todoContentHash,
|
|
||||||
stringSimilarity,
|
|
||||||
} from './utils/index.js';
|
|
||||||
import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js';
|
import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js';
|
||||||
import { MAX_TODOS_PER_SESSION } from './config/map-limits.js';
|
import { MAX_TODOS_PER_SESSION } from './config/map-limits.js';
|
||||||
|
|
||||||
@@ -151,8 +146,19 @@ const MAX_PLAN_HISTORY = 10;
|
|||||||
* P1-002: Configurable false positive prevention
|
* P1-002: Configurable false positive prevention
|
||||||
*/
|
*/
|
||||||
const COMMON_COMPLETION_PHRASES = new Set([
|
const COMMON_COMPLETION_PHRASES = new Set([
|
||||||
'DONE', 'COMPLETE', 'FINISHED', 'OK', 'YES', 'TRUE', 'SUCCESS',
|
'DONE',
|
||||||
'READY', 'COMPLETED', 'PASSED', 'END', 'STOP', 'EXIT',
|
'COMPLETE',
|
||||||
|
'FINISHED',
|
||||||
|
'OK',
|
||||||
|
'YES',
|
||||||
|
'TRUE',
|
||||||
|
'SUCCESS',
|
||||||
|
'READY',
|
||||||
|
'COMPLETED',
|
||||||
|
'PASSED',
|
||||||
|
'END',
|
||||||
|
'STOP',
|
||||||
|
'EXIT',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -249,9 +255,9 @@ const TODO_PLAIN_CHECKMARK_PATTERN = /✔\s+(.+)/g;
|
|||||||
* Prevents false positives from tool invocations and Claude commentary
|
* Prevents false positives from tool invocations and Claude commentary
|
||||||
*/
|
*/
|
||||||
const TODO_EXCLUDE_PATTERNS = [
|
const TODO_EXCLUDE_PATTERNS = [
|
||||||
/^(?:Bash|Search|Read|Write|Glob|Grep|Edit|Task)\s*\(/i, // Tool invocations
|
/^(?:Bash|Search|Read|Write|Glob|Grep|Edit|Task)\s*\(/i, // Tool invocations
|
||||||
/^(?:I'll |Let me |Now I|First,|Task \d+:|Result:|Error:)/i, // Claude commentary
|
/^(?:I'll |Let me |Now I|First,|Task \d+:|Result:|Error:)/i, // Claude commentary
|
||||||
/^\S+\([^)]+\)$/, // Generic function call pattern
|
/^\S+\([^)]+\)$/, // Generic function call pattern
|
||||||
];
|
];
|
||||||
|
|
||||||
// ---------- Loop Status Patterns ----------
|
// ---------- Loop Status Patterns ----------
|
||||||
@@ -313,7 +319,8 @@ const TODOWRITE_PATTERN = /TodoWrite|todo(?:s)?\s*(?:updated|written|saved)|Todo
|
|||||||
* Examples: "All 8 files have been created", "All tasks completed", "Everything is done"
|
* Examples: "All 8 files have been created", "All tasks completed", "Everything is done"
|
||||||
* Used to mark all tracked todos as complete at once
|
* Used to mark all tracked todos as complete at once
|
||||||
*/
|
*/
|
||||||
const ALL_COMPLETE_PATTERN = /all\s+(?:\d+\s+)?(?:tasks?|files?|items?)\s+(?:have\s+been\s+|are\s+)?(?:completed?|done|finished|created)|completed?\s+all\s+(?:\d+\s+)?tasks?|all\s+done|everything\s+(?:is\s+)?(?:completed?|done)|finished\s+all\s+tasks?/i;
|
const ALL_COMPLETE_PATTERN =
|
||||||
|
/all\s+(?:\d+\s+)?(?:tasks?|files?|items?)\s+(?:have\s+been\s+|are\s+)?(?:completed?|done|finished|created)|completed?\s+all\s+(?:\d+\s+)?tasks?|all\s+done|everything\s+(?:is\s+)?(?:completed?|done)|finished\s+all\s+tasks?/i;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts count from "all N items" messages
|
* Extracts count from "all N items" messages
|
||||||
@@ -327,7 +334,8 @@ const ALL_COUNT_PATTERN = /all\s+(\d+)\s+(?:tasks?|files?|items?)/i;
|
|||||||
* Examples: "Task #5 is done", "marked as completed", "todo 3 finished"
|
* Examples: "Task #5 is done", "marked as completed", "todo 3 finished"
|
||||||
* Used to update specific todo items by number
|
* Used to update specific todo items by number
|
||||||
*/
|
*/
|
||||||
const TASK_DONE_PATTERN = /(?:task|item|todo)\s*(?:#?\d+|"\s*[^"]+\s*")?\s*(?:is\s+)?(?:done|completed?|finished)|(?:completed?|done|finished)\s+(?:task|item)\s*(?:#?\d+)?|marking\s+(?:.*?\s+)?(?:as\s+)?completed?|marked\s+(?:.*?\s+)?(?:as\s+)?completed?/i;
|
const TASK_DONE_PATTERN =
|
||||||
|
/(?:task|item|todo)\s*(?:#?\d+|"\s*[^"]+\s*")?\s*(?:is\s+)?(?:done|completed?|finished)|(?:completed?|done|finished)\s+(?:task|item)\s*(?:#?\d+)?|marking\s+(?:.*?\s+)?(?:as\s+)?completed?|marked\s+(?:.*?\s+)?(?:as\s+)?completed?/i;
|
||||||
|
|
||||||
// ---------- Utility Patterns ----------
|
// ---------- Utility Patterns ----------
|
||||||
|
|
||||||
@@ -411,48 +419,48 @@ const COMPLETION_INDICATOR_PATTERNS = [
|
|||||||
|
|
||||||
/** P0 (Critical) priority patterns - highest severity issues */
|
/** P0 (Critical) priority patterns - highest severity issues */
|
||||||
const P0_PRIORITY_PATTERNS = [
|
const P0_PRIORITY_PATTERNS = [
|
||||||
/\bP0\b|\(P0\)|:?\s*P0\s*:/, // Explicit P0
|
/\bP0\b|\(P0\)|:?\s*P0\s*:/, // Explicit P0
|
||||||
/\bCRITICAL\b/, // Critical keyword
|
/\bCRITICAL\b/, // Critical keyword
|
||||||
/\bBLOCKER\b/, // Blocker
|
/\bBLOCKER\b/, // Blocker
|
||||||
/\bURGENT\b/, // Urgent
|
/\bURGENT\b/, // Urgent
|
||||||
/\bSECURITY\b/, // Security issues
|
/\bSECURITY\b/, // Security issues
|
||||||
/\bCRASH(?:ES|ING)?\b/, // Crash, crashes, crashing
|
/\bCRASH(?:ES|ING)?\b/, // Crash, crashes, crashing
|
||||||
/\bBROKEN\b/, // Broken
|
/\bBROKEN\b/, // Broken
|
||||||
/\bDATA\s*LOSS\b/, // Data loss
|
/\bDATA\s*LOSS\b/, // Data loss
|
||||||
/\bPRODUCTION\s*(?:DOWN|ISSUE|BUG)\b/, // Production issues
|
/\bPRODUCTION\s*(?:DOWN|ISSUE|BUG)\b/, // Production issues
|
||||||
/\bHOTFIX\b/, // Hotfix
|
/\bHOTFIX\b/, // Hotfix
|
||||||
/\bSEVERITY\s*1\b/, // Severity 1
|
/\bSEVERITY\s*1\b/, // Severity 1
|
||||||
];
|
];
|
||||||
|
|
||||||
/** P1 (High) priority patterns - important issues requiring attention */
|
/** P1 (High) priority patterns - important issues requiring attention */
|
||||||
const P1_PRIORITY_PATTERNS = [
|
const P1_PRIORITY_PATTERNS = [
|
||||||
/\bP1\b|\(P1\)|:?\s*P1\s*:/, // Explicit P1
|
/\bP1\b|\(P1\)|:?\s*P1\s*:/, // Explicit P1
|
||||||
/\bHIGH\s*PRIORITY\b/, // High priority
|
/\bHIGH\s*PRIORITY\b/, // High priority
|
||||||
/\bIMPORTANT\b/, // Important
|
/\bIMPORTANT\b/, // Important
|
||||||
/\bBUG\b/, // Bug
|
/\bBUG\b/, // Bug
|
||||||
/\bFIX\b/, // Fix (as task type)
|
/\bFIX\b/, // Fix (as task type)
|
||||||
/\bERROR\b/, // Error
|
/\bERROR\b/, // Error
|
||||||
/\bFAIL(?:S|ED|ING|URE)?\b/, // Fail variants
|
/\bFAIL(?:S|ED|ING|URE)?\b/, // Fail variants
|
||||||
/\bREGRESSION\b/, // Regression
|
/\bREGRESSION\b/, // Regression
|
||||||
/\bMUST\s*(?:HAVE|FIX|DO)\b/, // Must have/fix/do
|
/\bMUST\s*(?:HAVE|FIX|DO)\b/, // Must have/fix/do
|
||||||
/\bSEVERITY\s*2\b/, // Severity 2
|
/\bSEVERITY\s*2\b/, // Severity 2
|
||||||
/\bREQUIRED\b/, // Required
|
/\bREQUIRED\b/, // Required
|
||||||
];
|
];
|
||||||
|
|
||||||
/** P2 (Medium) priority patterns - lower priority improvements */
|
/** P2 (Medium) priority patterns - lower priority improvements */
|
||||||
const P2_PRIORITY_PATTERNS = [
|
const P2_PRIORITY_PATTERNS = [
|
||||||
/\bP2\b|\(P2\)|:?\s*P2\s*:/, // Explicit P2
|
/\bP2\b|\(P2\)|:?\s*P2\s*:/, // Explicit P2
|
||||||
/\bNICE\s*TO\s*HAVE\b/, // Nice to have
|
/\bNICE\s*TO\s*HAVE\b/, // Nice to have
|
||||||
/\bLOW\s*PRIORITY\b/, // Low priority
|
/\bLOW\s*PRIORITY\b/, // Low priority
|
||||||
/\bREFACTOR\b/, // Refactor
|
/\bREFACTOR\b/, // Refactor
|
||||||
/\bCLEANUP\b/, // Cleanup
|
/\bCLEANUP\b/, // Cleanup
|
||||||
/\bIMPROVE(?:MENT)?\b/, // Improve/Improvement
|
/\bIMPROVE(?:MENT)?\b/, // Improve/Improvement
|
||||||
/\bOPTIMIZ(?:E|ATION)\b/, // Optimize/Optimization
|
/\bOPTIMIZ(?:E|ATION)\b/, // Optimize/Optimization
|
||||||
/\bCONSIDER\b/, // Consider
|
/\bCONSIDER\b/, // Consider
|
||||||
/\bWOULD\s*BE\s*NICE\b/, // Would be nice
|
/\bWOULD\s*BE\s*NICE\b/, // Would be nice
|
||||||
/\bENHANCE(?:MENT)?\b/, // Enhance/Enhancement
|
/\bENHANCE(?:MENT)?\b/, // Enhance/Enhancement
|
||||||
/\bTECH(?:NICAL)?\s*DEBT\b/, // Tech debt
|
/\bTECH(?:NICAL)?\s*DEBT\b/, // Tech debt
|
||||||
/\bDOCUMENT(?:ATION)?\b/, // Documentation
|
/\bDOCUMENT(?:ATION)?\b/, // Documentation
|
||||||
];
|
];
|
||||||
|
|
||||||
// ========== Event Types ==========
|
// ========== Event Types ==========
|
||||||
@@ -950,7 +958,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
|
|
||||||
const wasEnabled = this._loopState.enabled;
|
const wasEnabled = this._loopState.enabled;
|
||||||
this._loopState = createInitialRalphTrackerState();
|
this._loopState = createInitialRalphTrackerState();
|
||||||
this._loopState.enabled = wasEnabled; // Keep enabled status
|
this._loopState.enabled = wasEnabled; // Keep enabled status
|
||||||
this._todos.clear();
|
this._todos.clear();
|
||||||
this._completionPhraseCount.clear();
|
this._completionPhraseCount.clear();
|
||||||
this._taskNumberToContent.clear();
|
this._taskNumberToContent.clear();
|
||||||
@@ -1241,7 +1249,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
|
|
||||||
// Check if all todos are complete (adds 20 points)
|
// Check if all todos are complete (adds 20 points)
|
||||||
const todoArray = Array.from(this._todos.values());
|
const todoArray = Array.from(this._todos.values());
|
||||||
if (todoArray.length > 0 && todoArray.every(t => t.status === 'completed')) {
|
if (todoArray.length > 0 && todoArray.every((t) => t.status === 'completed')) {
|
||||||
signals.allTodosComplete = true;
|
signals.allTodosComplete = true;
|
||||||
score += 20;
|
score += 20;
|
||||||
}
|
}
|
||||||
@@ -1262,10 +1270,12 @@ export class RalphTracker extends EventEmitter {
|
|||||||
if (context) {
|
if (context) {
|
||||||
const lowerContext = context.toLowerCase();
|
const lowerContext = context.toLowerCase();
|
||||||
// Deduct points if phrase appears in prompt-like context
|
// Deduct points if phrase appears in prompt-like context
|
||||||
if (lowerContext.includes('output:') ||
|
if (
|
||||||
lowerContext.includes('completion phrase') ||
|
lowerContext.includes('output:') ||
|
||||||
lowerContext.includes('output exactly') ||
|
lowerContext.includes('completion phrase') ||
|
||||||
lowerContext.includes('when done')) {
|
lowerContext.includes('output exactly') ||
|
||||||
|
lowerContext.includes('when done')
|
||||||
|
) {
|
||||||
signals.contextAppropriate = false;
|
signals.contextAppropriate = false;
|
||||||
score -= 20;
|
score -= 20;
|
||||||
} else {
|
} else {
|
||||||
@@ -1335,7 +1345,6 @@ export class RalphTracker extends EventEmitter {
|
|||||||
* Use this when the caller has already stripped ANSI to avoid redundant regex work.
|
* Use this when the caller has already stripped ANSI to avoid redundant regex work.
|
||||||
*/
|
*/
|
||||||
processCleanData(cleanData: string): void {
|
processCleanData(cleanData: string): void {
|
||||||
|
|
||||||
// If tracker is disabled, only check for patterns that should auto-enable it
|
// If tracker is disabled, only check for patterns that should auto-enable it
|
||||||
if (!this._loopState.enabled) {
|
if (!this._loopState.enabled) {
|
||||||
// Don't auto-enable if explicitly disabled by user setting
|
// Don't auto-enable if explicitly disabled by user setting
|
||||||
@@ -1396,14 +1405,21 @@ export class RalphTracker extends EventEmitter {
|
|||||||
// substrings that any pattern could match are present in the data.
|
// substrings that any pattern could match are present in the data.
|
||||||
// This avoids 12 regex tests on every PTY chunk (the common case).
|
// This avoids 12 regex tests on every PTY chunk (the common case).
|
||||||
if (
|
if (
|
||||||
!data.includes('<') && // <promise>, TodoWrite
|
!data.includes('<') && // <promise>, TodoWrite
|
||||||
!data.includes('ralph') && !data.includes('Ralph') &&
|
!data.includes('ralph') &&
|
||||||
!data.includes('Todo') && !data.includes('todo') &&
|
!data.includes('Ralph') &&
|
||||||
!data.includes('Iteration') && !data.includes('[') &&
|
!data.includes('Todo') &&
|
||||||
!data.includes('\u2610') && !data.includes('\u2612') && // ☐ ☒
|
!data.includes('todo') &&
|
||||||
|
!data.includes('Iteration') &&
|
||||||
|
!data.includes('[') &&
|
||||||
|
!data.includes('\u2610') &&
|
||||||
|
!data.includes('\u2612') && // ☐ ☒
|
||||||
!data.includes('\u2714') && // ✔
|
!data.includes('\u2714') && // ✔
|
||||||
!data.includes('Loop') && !data.includes('complete') &&
|
!data.includes('Loop') &&
|
||||||
!data.includes('COMPLETE') && !data.includes('Done') && !data.includes('DONE')
|
!data.includes('complete') &&
|
||||||
|
!data.includes('COMPLETE') &&
|
||||||
|
!data.includes('Done') &&
|
||||||
|
!data.includes('DONE')
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1677,8 +1693,8 @@ export class RalphTracker extends EventEmitter {
|
|||||||
// Avoid false positives: don't trigger on prompt context
|
// Avoid false positives: don't trigger on prompt context
|
||||||
const isNotInPromptContext = !line.includes('<promise>') && !line.includes('output:');
|
const isNotInPromptContext = !line.includes('<promise>') && !line.includes('output:');
|
||||||
// Also avoid triggering on "completion phrase is X" explanatory text
|
// Also avoid triggering on "completion phrase is X" explanatory text
|
||||||
const isNotExplanation = !line.toLowerCase().includes('completion phrase') &&
|
const isNotExplanation =
|
||||||
!line.toLowerCase().includes('output exactly');
|
!line.toLowerCase().includes('completion phrase') && !line.toLowerCase().includes('output exactly');
|
||||||
|
|
||||||
if (isNotInPromptContext && isNotExplanation) {
|
if (isNotInPromptContext && isNotExplanation) {
|
||||||
this.handleBareCompletionPhrase(expectedPhrase);
|
this.handleBareCompletionPhrase(expectedPhrase);
|
||||||
@@ -1786,7 +1802,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
|
|
||||||
if (matchedPhrase) {
|
if (matchedPhrase) {
|
||||||
// Use the matched phrase (canonical) for tracking
|
// Use the matched phrase (canonical) for tracking
|
||||||
const canonicalCount = (this._completionPhraseCount.get(matchedPhrase) || 0);
|
const canonicalCount = this._completionPhraseCount.get(matchedPhrase) || 0;
|
||||||
// Require 2nd+ occurrence of canonical phrase OR explicitly active loop.
|
// Require 2nd+ occurrence of canonical phrase OR explicitly active loop.
|
||||||
// First occurrence (count=1) is the prompt echo — not actual completion.
|
// First occurrence (count=1) is the prompt echo — not actual completion.
|
||||||
if (canonicalCount >= 2 || this._loopState.active) {
|
if (canonicalCount >= 2 || this._loopState.active) {
|
||||||
@@ -1862,7 +1878,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
* @fires phraseValidationWarning - When a risky phrase is detected
|
* @fires phraseValidationWarning - When a risky phrase is detected
|
||||||
*/
|
*/
|
||||||
private validateCompletionPhrase(phrase: string): void {
|
private validateCompletionPhrase(phrase: string): void {
|
||||||
const normalized = phrase.toUpperCase().replace(/[\s_\-\.]+/g, '');
|
const normalized = phrase.toUpperCase().replace(/[\s_\-.]+/g, '');
|
||||||
|
|
||||||
// Generate a suggested unique phrase
|
// Generate a suggested unique phrase
|
||||||
const uniqueSuffix = Date.now().toString(36).slice(-4).toUpperCase();
|
const uniqueSuffix = Date.now().toString(36).slice(-4).toUpperCase();
|
||||||
@@ -1870,7 +1886,9 @@ export class RalphTracker extends EventEmitter {
|
|||||||
|
|
||||||
// Check for common phrases
|
// Check for common phrases
|
||||||
if (COMMON_COMPLETION_PHRASES.has(normalized)) {
|
if (COMMON_COMPLETION_PHRASES.has(normalized)) {
|
||||||
console.warn(`[RalphTracker] Warning: Completion phrase "${phrase}" is very common and may cause false positives. Consider using: "${suggestedPhrase}"`);
|
console.warn(
|
||||||
|
`[RalphTracker] Warning: Completion phrase "${phrase}" is very common and may cause false positives. Consider using: "${suggestedPhrase}"`
|
||||||
|
);
|
||||||
this.emit('phraseValidationWarning', {
|
this.emit('phraseValidationWarning', {
|
||||||
phrase,
|
phrase,
|
||||||
reason: 'common',
|
reason: 'common',
|
||||||
@@ -1881,7 +1899,9 @@ export class RalphTracker extends EventEmitter {
|
|||||||
|
|
||||||
// Check for short phrases
|
// Check for short phrases
|
||||||
if (normalized.length < MIN_RECOMMENDED_PHRASE_LENGTH) {
|
if (normalized.length < MIN_RECOMMENDED_PHRASE_LENGTH) {
|
||||||
console.warn(`[RalphTracker] Warning: Completion phrase "${phrase}" is too short (${normalized.length} chars). Consider using: "${suggestedPhrase}"`);
|
console.warn(
|
||||||
|
`[RalphTracker] Warning: Completion phrase "${phrase}" is too short (${normalized.length} chars). Consider using: "${suggestedPhrase}"`
|
||||||
|
);
|
||||||
this.emit('phraseValidationWarning', {
|
this.emit('phraseValidationWarning', {
|
||||||
phrase,
|
phrase,
|
||||||
reason: 'short',
|
reason: 'short',
|
||||||
@@ -1892,7 +1912,9 @@ export class RalphTracker extends EventEmitter {
|
|||||||
|
|
||||||
// Check for numeric-only phrases
|
// Check for numeric-only phrases
|
||||||
if (/^\d+$/.test(normalized)) {
|
if (/^\d+$/.test(normalized)) {
|
||||||
console.warn(`[RalphTracker] Warning: Completion phrase "${phrase}" is numeric-only and may cause false positives. Consider using: "${suggestedPhrase}"`);
|
console.warn(
|
||||||
|
`[RalphTracker] Warning: Completion phrase "${phrase}" is numeric-only and may cause false positives. Consider using: "${suggestedPhrase}"`
|
||||||
|
);
|
||||||
this.emit('phraseValidationWarning', {
|
this.emit('phraseValidationWarning', {
|
||||||
phrase,
|
phrase,
|
||||||
reason: 'numeric',
|
reason: 'numeric',
|
||||||
@@ -1971,14 +1993,16 @@ export class RalphTracker extends EventEmitter {
|
|||||||
if (currentIter !== this._lastObservedIteration) {
|
if (currentIter !== this._lastObservedIteration) {
|
||||||
this._lastIterationChangeTime = Date.now();
|
this._lastIterationChangeTime = Date.now();
|
||||||
this._lastObservedIteration = currentIter;
|
this._lastObservedIteration = currentIter;
|
||||||
this._iterationStallWarned = false; // Reset warning on iteration change
|
this._iterationStallWarned = false; // Reset warning on iteration change
|
||||||
|
|
||||||
// P1-004: Reset circuit breaker on successful iteration progress
|
// P1-004: Reset circuit breaker on successful iteration progress
|
||||||
// If we're making progress, the loop is healthy
|
// If we're making progress, the loop is healthy
|
||||||
if (this._circuitBreaker.state === 'HALF_OPEN' ||
|
if (
|
||||||
this._circuitBreaker.consecutiveNoProgress > 0 ||
|
this._circuitBreaker.state === 'HALF_OPEN' ||
|
||||||
this._circuitBreaker.consecutiveSameError > 0 ||
|
this._circuitBreaker.consecutiveNoProgress > 0 ||
|
||||||
this._circuitBreaker.consecutiveTestsFailure > 0) {
|
this._circuitBreaker.consecutiveSameError > 0 ||
|
||||||
|
this._circuitBreaker.consecutiveTestsFailure > 0
|
||||||
|
) {
|
||||||
this._circuitBreaker.consecutiveNoProgress = 0;
|
this._circuitBreaker.consecutiveNoProgress = 0;
|
||||||
this._circuitBreaker.consecutiveSameError = 0;
|
this._circuitBreaker.consecutiveSameError = 0;
|
||||||
this._circuitBreaker.lastProgressIteration = currentIter;
|
this._circuitBreaker.lastProgressIteration = currentIter;
|
||||||
@@ -2106,7 +2130,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
const content = match[2].trim();
|
const content = match[2].trim();
|
||||||
|
|
||||||
// Skip if content matches exclude patterns (tool invocations, commentary)
|
// Skip if content matches exclude patterns (tool invocations, commentary)
|
||||||
const shouldExclude = TODO_EXCLUDE_PATTERNS.some(pattern => pattern.test(content));
|
const shouldExclude = TODO_EXCLUDE_PATTERNS.some((pattern) => pattern.test(content));
|
||||||
if (shouldExclude) continue;
|
if (shouldExclude) continue;
|
||||||
|
|
||||||
// Skip if content is too short or looks like partial garbage
|
// Skip if content is too short or looks like partial garbage
|
||||||
@@ -2155,9 +2179,8 @@ export class RalphTracker extends EventEmitter {
|
|||||||
while ((match = TODO_TASK_STATUS_PATTERN.exec(line)) !== null) {
|
while ((match = TODO_TASK_STATUS_PATTERN.exec(line)) !== null) {
|
||||||
const taskNum = parseInt(match[1], 10);
|
const taskNum = parseInt(match[1], 10);
|
||||||
const statusStr = match[2].trim();
|
const statusStr = match[2].trim();
|
||||||
const status: RalphTodoStatus = statusStr === 'completed' ? 'completed'
|
const status: RalphTodoStatus =
|
||||||
: statusStr === 'in progress' ? 'in_progress'
|
statusStr === 'completed' ? 'completed' : statusStr === 'in progress' ? 'in_progress' : 'pending';
|
||||||
: 'pending';
|
|
||||||
const content = this._taskNumberToContent.get(taskNum);
|
const content = this._taskNumberToContent.get(taskNum);
|
||||||
if (content) {
|
if (content) {
|
||||||
this.upsertTodo(content, status);
|
this.upsertTodo(content, status);
|
||||||
@@ -2172,7 +2195,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
while ((match = TODO_PLAIN_CHECKMARK_PATTERN.exec(line)) !== null) {
|
while ((match = TODO_PLAIN_CHECKMARK_PATTERN.exec(line)) !== null) {
|
||||||
const content = match[1].trim();
|
const content = match[1].trim();
|
||||||
// Skip if content matches exclude patterns
|
// Skip if content matches exclude patterns
|
||||||
const shouldExclude = TODO_EXCLUDE_PATTERNS.some(pattern => pattern.test(content));
|
const shouldExclude = TODO_EXCLUDE_PATTERNS.some((pattern) => pattern.test(content));
|
||||||
if (shouldExclude) continue;
|
if (shouldExclude) continue;
|
||||||
if (content.length < 5) continue;
|
if (content.length < 5) continue;
|
||||||
// Skip status/created/updated prefixed content (already handled above)
|
// Skip status/created/updated prefixed content (already handled above)
|
||||||
@@ -2204,17 +2227,17 @@ export class RalphTracker extends EventEmitter {
|
|||||||
switch (icon) {
|
switch (icon) {
|
||||||
case '✓':
|
case '✓':
|
||||||
case '✅':
|
case '✅':
|
||||||
case '☒': // Claude Code checked checkbox
|
case '☒': // Claude Code checked checkbox
|
||||||
case '◉': // Filled circle (completed)
|
case '◉': // Filled circle (completed)
|
||||||
case '●': // Solid circle (completed)
|
case '●': // Solid circle (completed)
|
||||||
return 'completed';
|
return 'completed';
|
||||||
case '◐': // Half-filled circle (in progress)
|
case '◐': // Half-filled circle (in progress)
|
||||||
case '⏳':
|
case '⏳':
|
||||||
case '⌛':
|
case '⌛':
|
||||||
case '🔄':
|
case '🔄':
|
||||||
return 'in_progress';
|
return 'in_progress';
|
||||||
case '☐': // Claude Code empty checkbox
|
case '☐': // Claude Code empty checkbox
|
||||||
case '○': // Empty circle
|
case '○': // Empty circle
|
||||||
default:
|
default:
|
||||||
return 'pending';
|
return 'pending';
|
||||||
}
|
}
|
||||||
@@ -2280,10 +2303,10 @@ export class RalphTracker extends EventEmitter {
|
|||||||
|
|
||||||
// Clean content: remove ANSI codes, collapse whitespace, trim
|
// Clean content: remove ANSI codes, collapse whitespace, trim
|
||||||
const cleanContent = content
|
const cleanContent = content
|
||||||
.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '') // Remove ANSI escape codes
|
.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '') // Remove ANSI escape codes
|
||||||
.replace(/\s+/g, ' ') // Collapse whitespace
|
.replace(/\s+/g, ' ') // Collapse whitespace
|
||||||
.trim();
|
.trim();
|
||||||
if (cleanContent.length < 5) return; // Skip very short content
|
if (cleanContent.length < 5) return; // Skip very short content
|
||||||
|
|
||||||
// Parse priority from content
|
// Parse priority from content
|
||||||
const priority = this.parsePriority(cleanContent);
|
const priority = this.parsePriority(cleanContent);
|
||||||
@@ -2402,8 +2425,8 @@ export class RalphTracker extends EventEmitter {
|
|||||||
private normalizeTodoContent(content: string): string {
|
private normalizeTodoContent(content: string): string {
|
||||||
if (!content) return '';
|
if (!content) return '';
|
||||||
return content
|
return content
|
||||||
.replace(/\s+/g, ' ') // Collapse whitespace
|
.replace(/\s+/g, ' ') // Collapse whitespace
|
||||||
.replace(/[^a-zA-Z0-9\s.,!?'"-]/g, '') // Remove special chars (keep punctuation)
|
.replace(/[^a-zA-Z0-9\s.,!?'"-]/g, '') // Remove special chars (keep punctuation)
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -2507,7 +2530,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
if (normalized.length < 30) {
|
if (normalized.length < 30) {
|
||||||
threshold = 0.95; // Very strict for short strings
|
threshold = 0.95; // Very strict for short strings
|
||||||
} else if (normalized.length < 60) {
|
} else if (normalized.length < 60) {
|
||||||
threshold = 0.90; // Strict for medium strings
|
threshold = 0.9; // Strict for medium strings
|
||||||
} else {
|
} else {
|
||||||
threshold = TODO_SIMILARITY_THRESHOLD; // 0.85 for longer strings
|
threshold = TODO_SIMILARITY_THRESHOLD; // 0.85 for longer strings
|
||||||
}
|
}
|
||||||
@@ -2563,14 +2586,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Moderate: Bugs, features, enhancements
|
// Moderate: Bugs, features, enhancements
|
||||||
const moderatePatterns = [
|
const moderatePatterns = [/\bbug\b/, /\bfeature\b/, /\benhance(?:ment)?\b/, /\bimplement\b/, /\badd\b/, /\bfix\b/];
|
||||||
/\bbug\b/,
|
|
||||||
/\bfeature\b/,
|
|
||||||
/\benhance(?:ment)?\b/,
|
|
||||||
/\bimplement\b/,
|
|
||||||
/\badd\b/,
|
|
||||||
/\bfix\b/,
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const pattern of complexPatterns) {
|
for (const pattern of complexPatterns) {
|
||||||
if (pattern.test(lower)) return 'complex';
|
if (pattern.test(lower)) return 'complex';
|
||||||
@@ -2609,10 +2625,10 @@ export class RalphTracker extends EventEmitter {
|
|||||||
|
|
||||||
// Default estimates (in ms) based on typical task durations
|
// Default estimates (in ms) based on typical task durations
|
||||||
const defaults = {
|
const defaults = {
|
||||||
trivial: 1 * 60 * 1000, // 1 minute
|
trivial: 1 * 60 * 1000, // 1 minute
|
||||||
simple: 3 * 60 * 1000, // 3 minutes
|
simple: 3 * 60 * 1000, // 3 minutes
|
||||||
moderate: 10 * 60 * 1000, // 10 minutes
|
moderate: 10 * 60 * 1000, // 10 minutes
|
||||||
complex: 30 * 60 * 1000, // 30 minutes
|
complex: 30 * 60 * 1000, // 30 minutes
|
||||||
};
|
};
|
||||||
return defaults[complexity];
|
return defaults[complexity];
|
||||||
}
|
}
|
||||||
@@ -2671,9 +2687,9 @@ export class RalphTracker extends EventEmitter {
|
|||||||
public getTodoProgress(): RalphTodoProgress {
|
public getTodoProgress(): RalphTodoProgress {
|
||||||
const todos = Array.from(this._todos.values());
|
const todos = Array.from(this._todos.values());
|
||||||
const total = todos.length;
|
const total = todos.length;
|
||||||
const completed = todos.filter(t => t.status === 'completed').length;
|
const completed = todos.filter((t) => t.status === 'completed').length;
|
||||||
const inProgress = todos.filter(t => t.status === 'in_progress').length;
|
const inProgress = todos.filter((t) => t.status === 'in_progress').length;
|
||||||
const pending = todos.filter(t => t.status === 'pending').length;
|
const pending = todos.filter((t) => t.status === 'pending').length;
|
||||||
|
|
||||||
const percentComplete = total > 0 ? Math.round((completed / total) * 100) : 0;
|
const percentComplete = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||||
|
|
||||||
@@ -2983,7 +2999,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
// Ensure enabled flag exists (backwards compatibility)
|
// Ensure enabled flag exists (backwards compatibility)
|
||||||
this._loopState = {
|
this._loopState = {
|
||||||
...loopState,
|
...loopState,
|
||||||
enabled: loopState.enabled ?? false, // Override after spread for backwards compat
|
enabled: loopState.enabled ?? false, // Override after spread for backwards compat
|
||||||
};
|
};
|
||||||
this._todos.clear();
|
this._todos.clear();
|
||||||
for (const todo of todos) {
|
for (const todo of todos) {
|
||||||
@@ -3068,7 +3084,9 @@ export class RalphTracker extends EventEmitter {
|
|||||||
if (!Number.isNaN(value) && value >= 0) {
|
if (!Number.isNaN(value) && value >= 0) {
|
||||||
block.tasksCompletedThisLoop = value;
|
block.tasksCompletedThisLoop = value;
|
||||||
} else {
|
} else {
|
||||||
parseErrors.push(`Invalid TASKS_COMPLETED_THIS_LOOP value: "${tasksMatch[1]}". Expected: non-negative integer`);
|
parseErrors.push(
|
||||||
|
`Invalid TASKS_COMPLETED_THIS_LOOP value: "${tasksMatch[1]}". Expected: non-negative integer`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
matched = true;
|
matched = true;
|
||||||
}
|
}
|
||||||
@@ -3104,7 +3122,9 @@ export class RalphTracker extends EventEmitter {
|
|||||||
if (['IMPLEMENTATION', 'TESTING', 'DOCUMENTATION', 'REFACTORING'].includes(value)) {
|
if (['IMPLEMENTATION', 'TESTING', 'DOCUMENTATION', 'REFACTORING'].includes(value)) {
|
||||||
block.workType = value as RalphWorkType;
|
block.workType = value as RalphWorkType;
|
||||||
} else {
|
} else {
|
||||||
parseErrors.push(`Invalid WORK_TYPE value: "${value}". Expected: IMPLEMENTATION, TESTING, DOCUMENTATION, or REFACTORING`);
|
parseErrors.push(
|
||||||
|
`Invalid WORK_TYPE value: "${value}". Expected: IMPLEMENTATION, TESTING, DOCUMENTATION, or REFACTORING`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
matched = true;
|
matched = true;
|
||||||
}
|
}
|
||||||
@@ -3126,7 +3146,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
// Track unknown fields for debugging (only if looks like a field)
|
// Track unknown fields for debugging (only if looks like a field)
|
||||||
if (!matched && trimmedLine.includes(':')) {
|
if (!matched && trimmedLine.includes(':')) {
|
||||||
const fieldName = trimmedLine.split(':')[0].trim().toUpperCase();
|
const fieldName = trimmedLine.split(':')[0].trim().toUpperCase();
|
||||||
if (fieldName && !['#', '//'].some(c => fieldName.startsWith(c))) {
|
if (fieldName && !['#', '//'].some((c) => fieldName.startsWith(c))) {
|
||||||
unknownFields.push(fieldName);
|
unknownFields.push(fieldName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3221,11 +3241,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
* @param status - Overall status from RALPH_STATUS
|
* @param status - Overall status from RALPH_STATUS
|
||||||
* @fires circuitBreakerUpdate - If state changes
|
* @fires circuitBreakerUpdate - If state changes
|
||||||
*/
|
*/
|
||||||
private updateCircuitBreaker(
|
private updateCircuitBreaker(hasProgress: boolean, testsStatus: RalphTestsStatus, status: RalphStatusValue): void {
|
||||||
hasProgress: boolean,
|
|
||||||
testsStatus: RalphTestsStatus,
|
|
||||||
status: RalphStatusValue
|
|
||||||
): void {
|
|
||||||
const prevState = this._circuitBreaker.state;
|
const prevState = this._circuitBreaker.state;
|
||||||
|
|
||||||
if (hasProgress) {
|
if (hasProgress) {
|
||||||
@@ -3319,7 +3335,11 @@ export class RalphTracker extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Get cumulative stats from status blocks.
|
* Get cumulative stats from status blocks.
|
||||||
*/
|
*/
|
||||||
get cumulativeStats(): { filesModified: number; tasksCompleted: number; completionIndicators: number } {
|
get cumulativeStats(): {
|
||||||
|
filesModified: number;
|
||||||
|
tasksCompleted: number;
|
||||||
|
completionIndicators: number;
|
||||||
|
} {
|
||||||
return {
|
return {
|
||||||
filesModified: this._totalFilesModified,
|
filesModified: this._totalFilesModified,
|
||||||
tasksCompleted: this._totalTasksCompleted,
|
tasksCompleted: this._totalTasksCompleted,
|
||||||
@@ -3456,7 +3476,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
const tasksHeaderPattern = /^##\s*Tasks/i;
|
const tasksHeaderPattern = /^##\s*Tasks/i;
|
||||||
|
|
||||||
// Pattern for todo items
|
// Pattern for todo items
|
||||||
const todoPattern = /^-\s*\[([ x\-])\]\s*(.+)$/;
|
const todoPattern = /^-\s*\[([ x-])\]\s*(.+)$/;
|
||||||
|
|
||||||
let inCompletedSection = false;
|
let inCompletedSection = false;
|
||||||
|
|
||||||
@@ -3504,7 +3524,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse priority from content if not in a priority section
|
// Parse priority from content if not in a priority section
|
||||||
const parsedPriority = inCompletedSection ? null : (currentPriority || this.parsePriority(content));
|
const parsedPriority = inCompletedSection ? null : currentPriority || this.parsePriority(content);
|
||||||
|
|
||||||
const id = this.generateTodoId(content);
|
const id = this.generateTodoId(content);
|
||||||
newTodos.push({
|
newTodos.push({
|
||||||
@@ -3535,17 +3555,19 @@ export class RalphTracker extends EventEmitter {
|
|||||||
* Initialize plan tasks from generated plan items.
|
* Initialize plan tasks from generated plan items.
|
||||||
* Called when wizard generates a new plan.
|
* Called when wizard generates a new plan.
|
||||||
*/
|
*/
|
||||||
initializePlanTasks(items: Array<{
|
initializePlanTasks(
|
||||||
id?: string;
|
items: Array<{
|
||||||
content: string;
|
id?: string;
|
||||||
priority?: 'P0' | 'P1' | 'P2' | null;
|
content: string;
|
||||||
verificationCriteria?: string;
|
priority?: 'P0' | 'P1' | 'P2' | null;
|
||||||
testCommand?: string;
|
verificationCriteria?: string;
|
||||||
dependencies?: string[];
|
testCommand?: string;
|
||||||
tddPhase?: TddPhase;
|
dependencies?: string[];
|
||||||
pairedWith?: string;
|
tddPhase?: TddPhase;
|
||||||
complexity?: 'low' | 'medium' | 'high';
|
pairedWith?: string;
|
||||||
}>): void {
|
complexity?: 'low' | 'medium' | 'high';
|
||||||
|
}>
|
||||||
|
): void {
|
||||||
// Save current plan to history before replacing
|
// Save current plan to history before replacing
|
||||||
if (this._planTasks.size > 0) {
|
if (this._planTasks.size > 0) {
|
||||||
this._savePlanToHistory('Plan replaced with new generation');
|
this._savePlanToHistory('Plan replaced with new generation');
|
||||||
@@ -3580,11 +3602,14 @@ export class RalphTracker extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Update a specific plan task's status, attempts, or error.
|
* Update a specific plan task's status, attempts, or error.
|
||||||
*/
|
*/
|
||||||
updatePlanTask(taskId: string, update: {
|
updatePlanTask(
|
||||||
status?: PlanTaskStatus;
|
taskId: string,
|
||||||
error?: string;
|
update: {
|
||||||
incrementAttempts?: boolean;
|
status?: PlanTaskStatus;
|
||||||
}): { success: boolean; task?: EnhancedPlanTask; error?: string } {
|
error?: string;
|
||||||
|
incrementAttempts?: boolean;
|
||||||
|
}
|
||||||
|
): { success: boolean; task?: EnhancedPlanTask; error?: string } {
|
||||||
const task = this._planTasks.get(taskId);
|
const task = this._planTasks.get(taskId);
|
||||||
if (!task) {
|
if (!task) {
|
||||||
return { success: false, error: 'Task not found' };
|
return { success: false, error: 'Task not found' };
|
||||||
@@ -3635,7 +3660,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
for (const [_, task] of this._planTasks) {
|
for (const [_, task] of this._planTasks) {
|
||||||
if (task.dependencies.includes(completedTaskId)) {
|
if (task.dependencies.includes(completedTaskId)) {
|
||||||
// Check if all dependencies are now complete
|
// Check if all dependencies are now complete
|
||||||
const allDepsComplete = task.dependencies.every(depId => {
|
const allDepsComplete = task.dependencies.every((depId) => {
|
||||||
const dep = this._planTasks.get(depId);
|
const dep = this._planTasks.get(depId);
|
||||||
return dep && dep.status === 'completed';
|
return dep && dep.status === 'completed';
|
||||||
});
|
});
|
||||||
@@ -3653,8 +3678,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
private _checkForCheckpoint(): void {
|
private _checkForCheckpoint(): void {
|
||||||
const currentIteration = this._loopState.cycleCount;
|
const currentIteration = this._loopState.cycleCount;
|
||||||
if (this._checkpointIterations.includes(currentIteration) &&
|
if (this._checkpointIterations.includes(currentIteration) && currentIteration > this._lastCheckpointIteration) {
|
||||||
currentIteration > this._lastCheckpointIteration) {
|
|
||||||
this._lastCheckpointIteration = currentIteration;
|
this._lastCheckpointIteration = currentIteration;
|
||||||
const checkpoint = this.generateCheckpointReview();
|
const checkpoint = this.generateCheckpointReview();
|
||||||
this.emit('planCheckpoint', checkpoint);
|
this.emit('planCheckpoint', checkpoint);
|
||||||
@@ -3669,17 +3693,17 @@ export class RalphTracker extends EventEmitter {
|
|||||||
|
|
||||||
const summary = {
|
const summary = {
|
||||||
total: tasks.length,
|
total: tasks.length,
|
||||||
completed: tasks.filter(t => t.status === 'completed').length,
|
completed: tasks.filter((t) => t.status === 'completed').length,
|
||||||
failed: tasks.filter(t => t.status === 'failed').length,
|
failed: tasks.filter((t) => t.status === 'failed').length,
|
||||||
blocked: tasks.filter(t => t.status === 'blocked').length,
|
blocked: tasks.filter((t) => t.status === 'blocked').length,
|
||||||
pending: tasks.filter(t => t.status === 'pending').length,
|
pending: tasks.filter((t) => t.status === 'pending').length,
|
||||||
inProgress: tasks.filter(t => t.status === 'in_progress').length,
|
inProgress: tasks.filter((t) => t.status === 'in_progress').length,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Find stuck tasks (3+ attempts or blocked)
|
// Find stuck tasks (3+ attempts or blocked)
|
||||||
const stuckTasks = tasks
|
const stuckTasks = tasks
|
||||||
.filter(t => t.attempts >= 3 || t.status === 'blocked')
|
.filter((t) => t.attempts >= 3 || t.status === 'blocked')
|
||||||
.map(t => ({
|
.map((t) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
content: t.content,
|
content: t.content,
|
||||||
attempts: t.attempts,
|
attempts: t.attempts,
|
||||||
@@ -3697,9 +3721,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
recommendations.push('More tasks have failed than completed. Review approach and consider plan adjustment.');
|
recommendations.push('More tasks have failed than completed. Review approach and consider plan adjustment.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressPercent = summary.total > 0
|
const progressPercent = summary.total > 0 ? Math.round((summary.completed / summary.total) * 100) : 0;
|
||||||
? Math.round((summary.completed / summary.total) * 100)
|
|
||||||
: 0;
|
|
||||||
if (progressPercent < 20 && this._loopState.cycleCount > 10) {
|
if (progressPercent < 20 && this._loopState.cycleCount > 10) {
|
||||||
recommendations.push('Progress is slow. Consider simplifying tasks or reviewing dependencies.');
|
recommendations.push('Progress is slow. Consider simplifying tasks or reviewing dependencies.');
|
||||||
}
|
}
|
||||||
@@ -3749,7 +3771,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
summary: string;
|
summary: string;
|
||||||
stats: { total: number; completed: number; failed: number };
|
stats: { total: number; completed: number; failed: number };
|
||||||
}> {
|
}> {
|
||||||
return this._planHistory.map(h => {
|
return this._planHistory.map((h) => {
|
||||||
const tasks = Array.from(h.tasks.values());
|
const tasks = Array.from(h.tasks.values());
|
||||||
return {
|
return {
|
||||||
version: h.version,
|
version: h.version,
|
||||||
@@ -3757,8 +3779,8 @@ export class RalphTracker extends EventEmitter {
|
|||||||
summary: h.summary,
|
summary: h.summary,
|
||||||
stats: {
|
stats: {
|
||||||
total: tasks.length,
|
total: tasks.length,
|
||||||
completed: tasks.filter(t => t.status === 'completed').length,
|
completed: tasks.filter((t) => t.status === 'completed').length,
|
||||||
failed: tasks.filter(t => t.status === 'failed').length,
|
failed: tasks.filter((t) => t.status === 'failed').length,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -3767,8 +3789,12 @@ export class RalphTracker extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Rollback to a previous plan version.
|
* Rollback to a previous plan version.
|
||||||
*/
|
*/
|
||||||
rollbackToVersion(version: number): { success: boolean; plan?: EnhancedPlanTask[]; error?: string } {
|
rollbackToVersion(version: number): {
|
||||||
const historyEntry = this._planHistory.find(h => h.version === version);
|
success: boolean;
|
||||||
|
plan?: EnhancedPlanTask[];
|
||||||
|
error?: string;
|
||||||
|
} {
|
||||||
|
const historyEntry = this._planHistory.find((h) => h.version === version);
|
||||||
if (!historyEntry) {
|
if (!historyEntry) {
|
||||||
return { success: false, error: `Version ${version} not found in history` };
|
return { success: false, error: `Version ${version} not found in history` };
|
||||||
}
|
}
|
||||||
@@ -3807,7 +3833,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
// Generate unique ID
|
// Generate unique ID
|
||||||
const existingIds = Array.from(this._planTasks.keys());
|
const existingIds = Array.from(this._planTasks.keys());
|
||||||
const prefix = task.priority || 'P1';
|
const prefix = task.priority || 'P1';
|
||||||
let counter = existingIds.filter(id => id.startsWith(prefix)).length + 1;
|
let counter = existingIds.filter((id) => id.startsWith(prefix)).length + 1;
|
||||||
let id = `${prefix}-${String(counter).padStart(3, '0')}`;
|
let id = `${prefix}-${String(counter).padStart(3, '0')}`;
|
||||||
while (this._planTasks.has(id)) {
|
while (this._planTasks.has(id)) {
|
||||||
counter++;
|
counter++;
|
||||||
@@ -3850,8 +3876,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
isCheckpointDue(): boolean {
|
isCheckpointDue(): boolean {
|
||||||
const currentIteration = this._loopState.cycleCount;
|
const currentIteration = this._loopState.cycleCount;
|
||||||
return this._checkpointIterations.includes(currentIteration) &&
|
return this._checkpointIterations.includes(currentIteration) && currentIteration > this._lastCheckpointIteration;
|
||||||
currentIteration > this._lastCheckpointIteration;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+270
-193
@@ -41,15 +41,8 @@ import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle-
|
|||||||
import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js';
|
import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js';
|
||||||
import type { TeamWatcher } from './team-watcher.js';
|
import type { TeamWatcher } from './team-watcher.js';
|
||||||
import { BufferAccumulator } from './utils/buffer-accumulator.js';
|
import { BufferAccumulator } from './utils/buffer-accumulator.js';
|
||||||
import {
|
import { ANSI_ESCAPE_PATTERN_SIMPLE, TOKEN_PATTERN, assertNever } from './utils/index.js';
|
||||||
ANSI_ESCAPE_PATTERN_SIMPLE,
|
import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js';
|
||||||
TOKEN_PATTERN,
|
|
||||||
assertNever,
|
|
||||||
} from './utils/index.js';
|
|
||||||
import {
|
|
||||||
MAX_RESPAWN_BUFFER_SIZE,
|
|
||||||
TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE,
|
|
||||||
} from './config/buffer-limits.js';
|
|
||||||
import type {
|
import type {
|
||||||
RespawnCycleMetrics,
|
RespawnCycleMetrics,
|
||||||
RespawnAggregateMetrics,
|
RespawnAggregateMetrics,
|
||||||
@@ -542,45 +535,45 @@ export interface RespawnEvents {
|
|||||||
|
|
||||||
/** Default configuration values */
|
/** Default configuration values */
|
||||||
const DEFAULT_CONFIG: RespawnConfig = {
|
const DEFAULT_CONFIG: RespawnConfig = {
|
||||||
idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback)
|
idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback)
|
||||||
updatePrompt: 'write a brief progress summary to CLAUDE.md noting what you accomplished, then continue working.',
|
updatePrompt: 'write a brief progress summary to CLAUDE.md noting what you accomplished, then continue working.',
|
||||||
interStepDelayMs: 1000, // 1 second between steps
|
interStepDelayMs: 1000, // 1 second between steps
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sendClear: true, // send /clear after update prompt
|
sendClear: true, // send /clear after update prompt
|
||||||
sendInit: true, // send /init after /clear
|
sendInit: true, // send /init after /clear
|
||||||
completionConfirmMs: 10000, // 10 seconds of silence after completion message
|
completionConfirmMs: 10000, // 10 seconds of silence after completion message
|
||||||
noOutputTimeoutMs: 30000, // 30 seconds fallback if no output at all
|
noOutputTimeoutMs: 30000, // 30 seconds fallback if no output at all
|
||||||
autoAcceptPrompts: true, // auto-accept plan mode prompts (not questions)
|
autoAcceptPrompts: true, // auto-accept plan mode prompts (not questions)
|
||||||
autoAcceptDelayMs: 8000, // 8 seconds before auto-accepting
|
autoAcceptDelayMs: 8000, // 8 seconds before auto-accepting
|
||||||
aiIdleCheckEnabled: true, // use AI to confirm idle state
|
aiIdleCheckEnabled: true, // use AI to confirm idle state
|
||||||
aiIdleCheckModel: 'claude-opus-4-5-20251101',
|
aiIdleCheckModel: 'claude-opus-4-5-20251101',
|
||||||
aiIdleCheckMaxContext: 16000, // ~4k tokens
|
aiIdleCheckMaxContext: 16000, // ~4k tokens
|
||||||
aiIdleCheckTimeoutMs: 90000, // 90 seconds (thinking can be slow)
|
aiIdleCheckTimeoutMs: 90000, // 90 seconds (thinking can be slow)
|
||||||
aiIdleCheckCooldownMs: 180000, // 3 minutes after WORKING verdict
|
aiIdleCheckCooldownMs: 180000, // 3 minutes after WORKING verdict
|
||||||
aiPlanCheckEnabled: true, // use AI to confirm plan mode before auto-accept
|
aiPlanCheckEnabled: true, // use AI to confirm plan mode before auto-accept
|
||||||
aiPlanCheckModel: 'claude-opus-4-5-20251101',
|
aiPlanCheckModel: 'claude-opus-4-5-20251101',
|
||||||
aiPlanCheckMaxContext: 8000, // ~2k tokens (plan mode UI is compact)
|
aiPlanCheckMaxContext: 8000, // ~2k tokens (plan mode UI is compact)
|
||||||
aiPlanCheckTimeoutMs: 60000, // 60 seconds (thinking can be slow)
|
aiPlanCheckTimeoutMs: 60000, // 60 seconds (thinking can be slow)
|
||||||
aiPlanCheckCooldownMs: 30000, // 30 seconds after NOT_PLAN_MODE
|
aiPlanCheckCooldownMs: 30000, // 30 seconds after NOT_PLAN_MODE
|
||||||
stuckStateDetectionEnabled: true, // detect stuck states
|
stuckStateDetectionEnabled: true, // detect stuck states
|
||||||
stuckStateWarningMs: 300000, // 5 minutes warning threshold
|
stuckStateWarningMs: 300000, // 5 minutes warning threshold
|
||||||
stuckStateRecoveryMs: 600000, // 10 minutes recovery threshold
|
stuckStateRecoveryMs: 600000, // 10 minutes recovery threshold
|
||||||
maxStuckRecoveries: 3, // max recovery attempts
|
maxStuckRecoveries: 3, // max recovery attempts
|
||||||
// P2-001: Adaptive timing
|
// P2-001: Adaptive timing
|
||||||
adaptiveTimingEnabled: true, // Use adaptive timing based on historical patterns
|
adaptiveTimingEnabled: true, // Use adaptive timing based on historical patterns
|
||||||
adaptiveMinConfirmMs: 5000, // Minimum 5 seconds
|
adaptiveMinConfirmMs: 5000, // Minimum 5 seconds
|
||||||
adaptiveMaxConfirmMs: 30000, // Maximum 30 seconds
|
adaptiveMaxConfirmMs: 30000, // Maximum 30 seconds
|
||||||
// P2-002: Skip-clear optimization
|
// P2-002: Skip-clear optimization
|
||||||
skipClearWhenLowContext: true, // Skip /clear when token count is low
|
skipClearWhenLowContext: true, // Skip /clear when token count is low
|
||||||
skipClearThresholdPercent: 30, // Skip if below 30% of max context
|
skipClearThresholdPercent: 30, // Skip if below 30% of max context
|
||||||
// P2-004: Cycle metrics
|
// P2-004: Cycle metrics
|
||||||
trackCycleMetrics: true, // Track and persist cycle metrics
|
trackCycleMetrics: true, // Track and persist cycle metrics
|
||||||
// P2-001: Confidence scoring
|
// P2-001: Confidence scoring
|
||||||
minIdleConfidence: 65, // Minimum confidence to trigger idle (0-100)
|
minIdleConfidence: 65, // Minimum confidence to trigger idle (0-100)
|
||||||
confidenceWeightCompletion: 40, // Weight for completion message
|
confidenceWeightCompletion: 40, // Weight for completion message
|
||||||
confidenceWeightSilence: 25, // Weight for output silence
|
confidenceWeightSilence: 25, // Weight for output silence
|
||||||
confidenceWeightTokens: 20, // Weight for token stability
|
confidenceWeightTokens: 20, // Weight for token stability
|
||||||
confidenceWeightNoWorking: 15, // Weight for working pattern absence
|
confidenceWeightNoWorking: 15, // Weight for working pattern absence
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -737,7 +730,8 @@ export class RespawnController extends EventEmitter {
|
|||||||
// ========== Timer Tracking for UI Countdown Display ==========
|
// ========== Timer Tracking for UI Countdown Display ==========
|
||||||
|
|
||||||
/** Active timers being tracked for UI display */
|
/** Active timers being tracked for UI display */
|
||||||
private activeTimers: Map<string, { name: string; startedAt: number; durationMs: number; endsAt: number }> = new Map();
|
private activeTimers: Map<string, { name: string; startedAt: number; durationMs: number; endsAt: number }> =
|
||||||
|
new Map();
|
||||||
|
|
||||||
/** Recent action log entries (for UI display, max 20) */
|
/** Recent action log entries (for UI display, max 20) */
|
||||||
private recentActions: ActionLogEntry[] = [];
|
private recentActions: ActionLogEntry[] = [];
|
||||||
@@ -818,9 +812,9 @@ export class RespawnController extends EventEmitter {
|
|||||||
* Used as secondary signals, not primary detection.
|
* Used as secondary signals, not primary detection.
|
||||||
*/
|
*/
|
||||||
private readonly PROMPT_PATTERNS = [
|
private readonly PROMPT_PATTERNS = [
|
||||||
'❯', // Standard prompt
|
'❯', // Standard prompt
|
||||||
'\u276f', // Unicode variant
|
'\u276f', // Unicode variant
|
||||||
'⏵', // Claude Code prompt variant
|
'⏵', // Claude Code prompt variant
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -829,15 +823,55 @@ export class RespawnController extends EventEmitter {
|
|||||||
* Note: ✻ and ✽ removed - they appear in completion messages too.
|
* Note: ✻ and ✽ removed - they appear in completion messages too.
|
||||||
*/
|
*/
|
||||||
private readonly WORKING_PATTERNS = [
|
private readonly WORKING_PATTERNS = [
|
||||||
'Thinking', 'Writing', 'Reading', 'Running', 'Searching',
|
'Thinking',
|
||||||
'Editing', 'Creating', 'Deleting', 'Analyzing', 'Executing',
|
'Writing',
|
||||||
'Synthesizing', 'Brewing', // Claude's processing indicators
|
'Reading',
|
||||||
'Compiling', 'Building', 'Installing', 'Fetching', 'Downloading',
|
'Running',
|
||||||
'Processing', 'Generating', 'Loading', 'Starting', 'Updating',
|
'Searching',
|
||||||
'Checking', 'Validating', 'Testing', 'Formatting', 'Linting',
|
'Editing',
|
||||||
'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏', // Spinner chars
|
'Creating',
|
||||||
'◐', '◓', '◑', '◒', // Alternative spinners
|
'Deleting',
|
||||||
'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷', // Braille spinners
|
'Analyzing',
|
||||||
|
'Executing',
|
||||||
|
'Synthesizing',
|
||||||
|
'Brewing', // Claude's processing indicators
|
||||||
|
'Compiling',
|
||||||
|
'Building',
|
||||||
|
'Installing',
|
||||||
|
'Fetching',
|
||||||
|
'Downloading',
|
||||||
|
'Processing',
|
||||||
|
'Generating',
|
||||||
|
'Loading',
|
||||||
|
'Starting',
|
||||||
|
'Updating',
|
||||||
|
'Checking',
|
||||||
|
'Validating',
|
||||||
|
'Testing',
|
||||||
|
'Formatting',
|
||||||
|
'Linting',
|
||||||
|
'⠋',
|
||||||
|
'⠙',
|
||||||
|
'⠹',
|
||||||
|
'⠸',
|
||||||
|
'⠼',
|
||||||
|
'⠴',
|
||||||
|
'⠦',
|
||||||
|
'⠧',
|
||||||
|
'⠇',
|
||||||
|
'⠏', // Spinner chars
|
||||||
|
'◐',
|
||||||
|
'◓',
|
||||||
|
'◑',
|
||||||
|
'◒', // Alternative spinners
|
||||||
|
'⣾',
|
||||||
|
'⣽',
|
||||||
|
'⣻',
|
||||||
|
'⢿',
|
||||||
|
'⡿',
|
||||||
|
'⣟',
|
||||||
|
'⣯',
|
||||||
|
'⣷', // Braille spinners
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1200,7 +1234,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
const prevState = this._state;
|
const prevState = this._state;
|
||||||
this._state = newState;
|
this._state = newState;
|
||||||
this.stateEnteredAt = Date.now();
|
this.stateEnteredAt = Date.now();
|
||||||
this.stuckStateWarned = false; // Reset warning for new state
|
this.stuckStateWarned = false; // Reset warning for new state
|
||||||
this.log(`State: ${prevState} → ${newState}`);
|
this.log(`State: ${prevState} → ${newState}`);
|
||||||
this.logAction('state', `${prevState} → ${newState}`);
|
this.logAction('state', `${prevState} → ${newState}`);
|
||||||
this.emit('stateChanged', newState, prevState);
|
this.emit('stateChanged', newState, prevState);
|
||||||
@@ -1518,7 +1552,9 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.setState('watching');
|
this.setState('watching');
|
||||||
} else {
|
} else {
|
||||||
// Real content (not just escape codes or single chars) - cancel confirmation
|
// Real content (not just escape codes or single chars) - cancel confirmation
|
||||||
this.log(`Substantial output during confirmation ("${stripped.substring(0, 40)}..."), cancelling idle detection`);
|
this.log(
|
||||||
|
`Substantial output during confirmation ("${stripped.substring(0, 40)}..."), cancelling idle detection`
|
||||||
|
);
|
||||||
this.cancelCompletionConfirm();
|
this.cancelCompletionConfirm();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -1526,7 +1562,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Legacy fallback: detect prompt characters (still useful for waiting_* states)
|
// Legacy fallback: detect prompt characters (still useful for waiting_* states)
|
||||||
const hasPrompt = this.PROMPT_PATTERNS.some(pattern => data.includes(pattern));
|
const hasPrompt = this.PROMPT_PATTERNS.some((pattern) => data.includes(pattern));
|
||||||
if (hasPrompt) {
|
if (hasPrompt) {
|
||||||
this.promptDetected = true;
|
this.promptDetected = true;
|
||||||
this.workingDetected = false;
|
this.workingDetected = false;
|
||||||
@@ -1706,7 +1742,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
if (this._state === 'stopped') return;
|
if (this._state === 'stopped') return;
|
||||||
const prompt = this.config.kickstartPrompt!;
|
const prompt = this.config.kickstartPrompt!;
|
||||||
this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`);
|
this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`);
|
||||||
await this.session.writeViaMux(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
|
await this.session.writeViaMux(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
|
||||||
this.emit('stepSent', 'kickstart', prompt);
|
this.emit('stepSent', 'kickstart', prompt);
|
||||||
this.setState('waiting_kickstart');
|
this.setState('waiting_kickstart');
|
||||||
this.promptDetected = false;
|
this.promptDetected = false;
|
||||||
@@ -1831,11 +1867,16 @@ export class RespawnController extends EventEmitter {
|
|||||||
if (this.stuckRecoveryCount < this.config.maxStuckRecoveries) {
|
if (this.stuckRecoveryCount < this.config.maxStuckRecoveries) {
|
||||||
this.stuckRecoveryCount++;
|
this.stuckRecoveryCount++;
|
||||||
this.logAction('stuck', `Recovery attempt ${this.stuckRecoveryCount}/${this.config.maxStuckRecoveries}`);
|
this.logAction('stuck', `Recovery attempt ${this.stuckRecoveryCount}/${this.config.maxStuckRecoveries}`);
|
||||||
this.log(`Stuck-state recovery triggered (state: ${this._state}, duration: ${Math.round(durationMs / 1000)}s, attempt: ${this.stuckRecoveryCount})`);
|
this.log(
|
||||||
|
`Stuck-state recovery triggered (state: ${this._state}, duration: ${Math.round(durationMs / 1000)}s, attempt: ${this.stuckRecoveryCount})`
|
||||||
|
);
|
||||||
this.emit('stuckStateRecovery', this._state, durationMs, this.stuckRecoveryCount);
|
this.emit('stuckStateRecovery', this._state, durationMs, this.stuckRecoveryCount);
|
||||||
this.handleStuckStateRecovery();
|
this.handleStuckStateRecovery();
|
||||||
} else {
|
} else {
|
||||||
this.logAction('stuck', `Max recoveries (${this.config.maxStuckRecoveries}) reached - manual intervention needed`);
|
this.logAction(
|
||||||
|
'stuck',
|
||||||
|
`Max recoveries (${this.config.maxStuckRecoveries}) reached - manual intervention needed`
|
||||||
|
);
|
||||||
this.log(`Stuck-state: max recoveries reached, manual intervention needed`);
|
this.log(`Stuck-state: max recoveries reached, manual intervention needed`);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -1950,12 +1991,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
* Start a tracked timer with UI countdown support.
|
* Start a tracked timer with UI countdown support.
|
||||||
* Emits timerStarted event and tracks the timer for UI display.
|
* Emits timerStarted event and tracks the timer for UI display.
|
||||||
*/
|
*/
|
||||||
private startTrackedTimer(
|
private startTrackedTimer(name: string, durationMs: number, callback: () => void, reason?: string): NodeJS.Timeout {
|
||||||
name: string,
|
|
||||||
durationMs: number,
|
|
||||||
callback: () => void,
|
|
||||||
reason?: string
|
|
||||||
): NodeJS.Timeout {
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const endsAt = now + durationMs;
|
const endsAt = now + durationMs;
|
||||||
|
|
||||||
@@ -1989,7 +2025,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
getActiveTimers(): ActiveTimerInfo[] {
|
getActiveTimers(): ActiveTimerInfo[] {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return Array.from(this.activeTimers.values()).map(t => ({
|
return Array.from(this.activeTimers.values()).map((t) => ({
|
||||||
name: t.name,
|
name: t.name,
|
||||||
remainingMs: Math.max(0, t.endsAt - now),
|
remainingMs: Math.max(0, t.endsAt - now),
|
||||||
totalMs: t.durationMs,
|
totalMs: t.durationMs,
|
||||||
@@ -2038,7 +2074,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check the rolling window (includes current data, catches both complete and split patterns)
|
// Check the rolling window (includes current data, catches both complete and split patterns)
|
||||||
return this.WORKING_PATTERNS.some(pattern => this.workingPatternWindow.includes(pattern));
|
return this.WORKING_PATTERNS.some((pattern) => this.workingPatternWindow.includes(pattern));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2175,7 +2211,9 @@ export class RespawnController extends EventEmitter {
|
|||||||
|
|
||||||
// If on cooldown, don't start check - wait for cooldown to expire
|
// If on cooldown, don't start check - wait for cooldown to expire
|
||||||
if (this.aiChecker.isOnCooldown()) {
|
if (this.aiChecker.isOnCooldown()) {
|
||||||
this.log(`AI check on cooldown (${Math.ceil(this.aiChecker.getCooldownRemainingMs() / 1000)}s remaining), waiting...`);
|
this.log(
|
||||||
|
`AI check on cooldown (${Math.ceil(this.aiChecker.getCooldownRemainingMs() / 1000)}s remaining), waiting...`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2207,67 +2245,70 @@ export class RespawnController extends EventEmitter {
|
|||||||
// Get the terminal buffer for analysis
|
// Get the terminal buffer for analysis
|
||||||
const buffer = this.terminalBuffer.value;
|
const buffer = this.terminalBuffer.value;
|
||||||
|
|
||||||
this.aiChecker.check(buffer).then((result) => {
|
this.aiChecker
|
||||||
// If state changed while checking (e.g., cancelled), ignore result
|
.check(buffer)
|
||||||
if (this._state !== 'ai_checking') {
|
.then((result) => {
|
||||||
this.log(`AI check result ignored (state is now ${this._state})`);
|
// If state changed while checking (e.g., cancelled), ignore result
|
||||||
return;
|
if (this._state !== 'ai_checking') {
|
||||||
}
|
this.log(`AI check result ignored (state is now ${this._state})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate this is the result for the current check (not a stale one)
|
// Validate this is the result for the current check (not a stale one)
|
||||||
if (this._currentAiCheckId !== checkId) {
|
if (this._currentAiCheckId !== checkId) {
|
||||||
this.log(`AI check result ignored (stale check ID: ${checkId.substring(0, 8)})`);
|
this.log(`AI check result ignored (stale check ID: ${checkId.substring(0, 8)})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.verdict === 'IDLE') {
|
if (result.verdict === 'IDLE') {
|
||||||
// Cancel any pending confirmation timers - AI has spoken
|
// Cancel any pending confirmation timers - AI has spoken
|
||||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: IDLE');
|
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: IDLE');
|
||||||
this.completionConfirmTimer = null;
|
this.completionConfirmTimer = null;
|
||||||
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'AI verdict: IDLE');
|
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'AI verdict: IDLE');
|
||||||
this.preFilterTimer = null;
|
this.preFilterTimer = null;
|
||||||
|
|
||||||
this.logAction('ai-check', `Verdict: IDLE - ${result.reasoning}`);
|
this.logAction('ai-check', `Verdict: IDLE - ${result.reasoning}`);
|
||||||
this.emit('aiCheckCompleted', result);
|
this.emit('aiCheckCompleted', result);
|
||||||
this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`);
|
this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`);
|
||||||
} else if (result.verdict === 'WORKING') {
|
} else if (result.verdict === 'WORKING') {
|
||||||
// Cancel timers and go to cooldown
|
// Cancel timers and go to cooldown
|
||||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: WORKING');
|
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: WORKING');
|
||||||
this.completionConfirmTimer = null;
|
this.completionConfirmTimer = null;
|
||||||
|
|
||||||
this.logAction('ai-check', `Verdict: WORKING - ${result.reasoning}`);
|
this.logAction('ai-check', `Verdict: WORKING - ${result.reasoning}`);
|
||||||
this.emit('aiCheckCompleted', result);
|
this.emit('aiCheckCompleted', result);
|
||||||
this.setState('watching');
|
this.setState('watching');
|
||||||
this.log(`AI check says WORKING, returning to watching with ${this.config.aiIdleCheckCooldownMs}ms cooldown`);
|
this.log(`AI check says WORKING, returning to watching with ${this.config.aiIdleCheckCooldownMs}ms cooldown`);
|
||||||
// Restart timers so the controller retries after cooldown expires
|
// Restart timers so the controller retries after cooldown expires
|
||||||
this.startNoOutputTimer();
|
this.startNoOutputTimer();
|
||||||
this.startPreFilterTimer();
|
this.startPreFilterTimer();
|
||||||
} else {
|
} else {
|
||||||
// ERROR verdict
|
// ERROR verdict
|
||||||
this.logAction('ai-check', `Error: ${result.reasoning}`);
|
this.logAction('ai-check', `Error: ${result.reasoning}`);
|
||||||
this.emit('aiCheckFailed', result.reasoning);
|
this.emit('aiCheckFailed', result.reasoning);
|
||||||
this.setState('watching');
|
this.setState('watching');
|
||||||
// Restart timers to allow retry
|
// Restart timers to allow retry
|
||||||
this.startNoOutputTimer();
|
this.startNoOutputTimer();
|
||||||
this.startPreFilterTimer();
|
this.startPreFilterTimer();
|
||||||
}
|
}
|
||||||
}).catch((err) => {
|
})
|
||||||
// Validate this is the error for the current check
|
.catch((err) => {
|
||||||
if (this._currentAiCheckId !== checkId) {
|
// Validate this is the error for the current check
|
||||||
return; // Stale check, ignore error
|
if (this._currentAiCheckId !== checkId) {
|
||||||
}
|
return; // Stale check, ignore error
|
||||||
if (this._state === 'stopped') return; // Guard against stopped state
|
}
|
||||||
if (this._state === 'ai_checking') {
|
if (this._state === 'stopped') return; // Guard against stopped state
|
||||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
if (this._state === 'ai_checking') {
|
||||||
this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`);
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
this.emit('aiCheckFailed', errorMsg);
|
this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`);
|
||||||
this.setState('watching');
|
this.emit('aiCheckFailed', errorMsg);
|
||||||
this.log(`AI check error: ${errorMsg}`);
|
this.setState('watching');
|
||||||
// Restart timers to allow retry
|
this.log(`AI check error: ${errorMsg}`);
|
||||||
this.startNoOutputTimer();
|
// Restart timers to allow retry
|
||||||
this.startPreFilterTimer();
|
this.startNoOutputTimer();
|
||||||
}
|
this.startPreFilterTimer();
|
||||||
});
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Auto-Accept Prompt Methods ==========
|
// ========== Auto-Accept Prompt Methods ==========
|
||||||
@@ -2351,7 +2392,9 @@ export class RespawnController extends EventEmitter {
|
|||||||
// Stage 2: AI confirmation (if enabled and available)
|
// Stage 2: AI confirmation (if enabled and available)
|
||||||
if (this.config.aiPlanCheckEnabled && this.planChecker.status !== 'disabled') {
|
if (this.config.aiPlanCheckEnabled && this.planChecker.status !== 'disabled') {
|
||||||
if (this.planChecker.isOnCooldown()) {
|
if (this.planChecker.isOnCooldown()) {
|
||||||
this.log(`Skipping auto-accept: plan checker on cooldown (${Math.ceil(this.planChecker.getCooldownRemainingMs() / 1000)}s remaining)`);
|
this.log(
|
||||||
|
`Skipping auto-accept: plan checker on cooldown (${Math.ceil(this.planChecker.getCooldownRemainingMs() / 1000)}s remaining)`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.planChecker.status === 'checking') {
|
if (this.planChecker.status === 'checking') {
|
||||||
@@ -2396,7 +2439,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
// Working patterns before the selector are from earlier work and don't matter.
|
// Working patterns before the selector are from earlier work and don't matter.
|
||||||
const selectorIndex = stripped.lastIndexOf(selectorMatch[0]);
|
const selectorIndex = stripped.lastIndexOf(selectorMatch[0]);
|
||||||
const afterSelector = stripped.slice(selectorIndex + selectorMatch[0].length);
|
const afterSelector = stripped.slice(selectorIndex + selectorMatch[0].length);
|
||||||
const hasWorking = this.WORKING_PATTERNS.some(pattern => afterSelector.includes(pattern));
|
const hasWorking = this.WORKING_PATTERNS.some((pattern) => afterSelector.includes(pattern));
|
||||||
if (hasWorking) return false;
|
if (hasWorking) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -2416,36 +2459,39 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.logAction('plan-check', 'Spawning AI plan checker');
|
this.logAction('plan-check', 'Spawning AI plan checker');
|
||||||
this.emit('planCheckStarted');
|
this.emit('planCheckStarted');
|
||||||
|
|
||||||
this.planChecker.check(buffer).then((result) => {
|
this.planChecker
|
||||||
// Discard stale result if new output arrived during check
|
.check(buffer)
|
||||||
if (this.lastOutputTime > this.planCheckStartTime) {
|
.then((result) => {
|
||||||
this.logAction('plan-check', 'Result discarded (output arrived during check)');
|
// Discard stale result if new output arrived during check
|
||||||
return;
|
if (this.lastOutputTime > this.planCheckStartTime) {
|
||||||
}
|
this.logAction('plan-check', 'Result discarded (output arrived during check)');
|
||||||
|
|
||||||
if (result.verdict === 'PLAN_MODE') {
|
|
||||||
// Don't send Enter if state changed (e.g., AI idle check started or respawn cycle began)
|
|
||||||
if (this._state !== 'watching') {
|
|
||||||
this.logAction('plan-check', `Verdict: PLAN_MODE but state is ${this._state}, not sending Enter`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.emit('planCheckCompleted', result);
|
|
||||||
this.logAction('plan-check', 'Verdict: PLAN_MODE - sending Enter immediately');
|
if (result.verdict === 'PLAN_MODE') {
|
||||||
this.sendAutoAcceptEnter();
|
// Don't send Enter if state changed (e.g., AI idle check started or respawn cycle began)
|
||||||
// No cooldown needed - we're taking action
|
if (this._state !== 'watching') {
|
||||||
} else if (result.verdict === 'NOT_PLAN_MODE') {
|
this.logAction('plan-check', `Verdict: PLAN_MODE but state is ${this._state}, not sending Enter`);
|
||||||
this.emit('planCheckCompleted', result);
|
return;
|
||||||
this.logAction('plan-check', `Verdict: NOT_PLAN_MODE - ${result.reasoning}`);
|
}
|
||||||
} else {
|
this.emit('planCheckCompleted', result);
|
||||||
// ERROR verdict
|
this.logAction('plan-check', 'Verdict: PLAN_MODE - sending Enter immediately');
|
||||||
this.emit('planCheckFailed', result.reasoning);
|
this.sendAutoAcceptEnter();
|
||||||
this.logAction('plan-check', `Error: ${result.reasoning}`);
|
// No cooldown needed - we're taking action
|
||||||
}
|
} else if (result.verdict === 'NOT_PLAN_MODE') {
|
||||||
}).catch((err) => {
|
this.emit('planCheckCompleted', result);
|
||||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
this.logAction('plan-check', `Verdict: NOT_PLAN_MODE - ${result.reasoning}`);
|
||||||
this.emit('planCheckFailed', errorMsg);
|
} else {
|
||||||
this.logAction('plan-check', `Failed: ${errorMsg.substring(0, 50)}`);
|
// ERROR verdict
|
||||||
});
|
this.emit('planCheckFailed', result.reasoning);
|
||||||
|
this.logAction('plan-check', `Error: ${result.reasoning}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
this.emit('planCheckFailed', errorMsg);
|
||||||
|
this.logAction('plan-check', `Failed: ${errorMsg.substring(0, 50)}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2765,11 +2811,13 @@ export class RespawnController extends EventEmitter {
|
|||||||
|
|
||||||
this.log(`Idle confirmed via: ${reason}`);
|
this.log(`Idle confirmed via: ${reason}`);
|
||||||
const status = this.getDetectionStatus();
|
const status = this.getDetectionStatus();
|
||||||
this.log(`Detection status: confidence=${status.confidenceLevel}%, ` +
|
this.log(
|
||||||
`completion=${status.completionMessageDetected}, ` +
|
`Detection status: confidence=${status.confidenceLevel}%, ` +
|
||||||
`silent=${status.outputSilent}, ` +
|
`completion=${status.completionMessageDetected}, ` +
|
||||||
`tokensStable=${status.tokensStable}, ` +
|
`silent=${status.outputSilent}, ` +
|
||||||
`noWorking=${status.workingPatternsAbsent}`);
|
`tokensStable=${status.tokensStable}, ` +
|
||||||
|
`noWorking=${status.workingPatternsAbsent}`
|
||||||
|
);
|
||||||
|
|
||||||
// ========== Agent Teams Integration ==========
|
// ========== Agent Teams Integration ==========
|
||||||
// Check if session has active teammates — don't respawn while team is working
|
// Check if session has active teammates — don't respawn while team is working
|
||||||
@@ -2777,7 +2825,10 @@ export class RespawnController extends EventEmitter {
|
|||||||
const count = this.teamWatcher.getActiveTeammateCount(this.session.id);
|
const count = this.teamWatcher.getActiveTeammateCount(this.session.id);
|
||||||
this.log(`Respawn blocked - ${count} active teammate(s) working`);
|
this.log(`Respawn blocked - ${count} active teammate(s) working`);
|
||||||
this.logAction('team', `Active teammates: ${count}`);
|
this.logAction('team', `Active teammates: ${count}`);
|
||||||
this.emit('respawnBlocked', { reason: 'active_teammates', details: `${count} teammate(s) still working` });
|
this.emit('respawnBlocked', {
|
||||||
|
reason: 'active_teammates',
|
||||||
|
details: `${count} teammate(s) still working`,
|
||||||
|
});
|
||||||
this.setState('watching');
|
this.setState('watching');
|
||||||
this.startNoOutputTimer();
|
this.startNoOutputTimer();
|
||||||
this.startPreFilterTimer();
|
this.startPreFilterTimer();
|
||||||
@@ -2792,7 +2843,10 @@ export class RespawnController extends EventEmitter {
|
|||||||
if (circuitBreaker.state === 'OPEN') {
|
if (circuitBreaker.state === 'OPEN') {
|
||||||
this.log(`Respawn blocked - Circuit breaker OPEN: ${circuitBreaker.reason}`);
|
this.log(`Respawn blocked - Circuit breaker OPEN: ${circuitBreaker.reason}`);
|
||||||
this.logAction('ralph', `Circuit breaker OPEN: ${circuitBreaker.reason}`);
|
this.logAction('ralph', `Circuit breaker OPEN: ${circuitBreaker.reason}`);
|
||||||
this.emit('respawnBlocked', { reason: 'circuit_breaker_open', details: circuitBreaker.reason });
|
this.emit('respawnBlocked', {
|
||||||
|
reason: 'circuit_breaker_open',
|
||||||
|
details: circuitBreaker.reason,
|
||||||
|
});
|
||||||
this.setState('watching');
|
this.setState('watching');
|
||||||
// Don't restart timers - wait for manual reset or circuit breaker resolution
|
// Don't restart timers - wait for manual reset or circuit breaker resolution
|
||||||
return;
|
return;
|
||||||
@@ -2804,7 +2858,10 @@ export class RespawnController extends EventEmitter {
|
|||||||
if (statusBlock?.exitSignal) {
|
if (statusBlock?.exitSignal) {
|
||||||
this.log(`Respawn paused - RALPH_STATUS EXIT_SIGNAL=true`);
|
this.log(`Respawn paused - RALPH_STATUS EXIT_SIGNAL=true`);
|
||||||
this.logAction('ralph', `Exit signal detected: ${statusBlock.recommendation || 'Task complete'}`);
|
this.logAction('ralph', `Exit signal detected: ${statusBlock.recommendation || 'Task complete'}`);
|
||||||
this.emit('respawnBlocked', { reason: 'exit_signal', details: statusBlock.recommendation || 'Task complete' });
|
this.emit('respawnBlocked', {
|
||||||
|
reason: 'exit_signal',
|
||||||
|
details: statusBlock.recommendation || 'Task complete',
|
||||||
|
});
|
||||||
this.setState('watching');
|
this.setState('watching');
|
||||||
// Don't restart timers - loop is complete
|
// Don't restart timers - loop is complete
|
||||||
return;
|
return;
|
||||||
@@ -2814,7 +2871,10 @@ export class RespawnController extends EventEmitter {
|
|||||||
if (statusBlock?.status === 'BLOCKED') {
|
if (statusBlock?.status === 'BLOCKED') {
|
||||||
this.log(`Respawn blocked - RALPH_STATUS reports BLOCKED`);
|
this.log(`Respawn blocked - RALPH_STATUS reports BLOCKED`);
|
||||||
this.logAction('ralph', `Claude reported BLOCKED: ${statusBlock.recommendation || 'Needs human intervention'}`);
|
this.logAction('ralph', `Claude reported BLOCKED: ${statusBlock.recommendation || 'Needs human intervention'}`);
|
||||||
this.emit('respawnBlocked', { reason: 'status_blocked', details: statusBlock.recommendation || 'Needs human intervention' });
|
this.emit('respawnBlocked', {
|
||||||
|
reason: 'status_blocked',
|
||||||
|
details: statusBlock.recommendation || 'Needs human intervention',
|
||||||
|
});
|
||||||
this.setState('watching');
|
this.setState('watching');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2846,7 +2906,10 @@ export class RespawnController extends EventEmitter {
|
|||||||
if (this.session.status === 'error') {
|
if (this.session.status === 'error') {
|
||||||
this.log('Skipping respawn cycle - session is in error state');
|
this.log('Skipping respawn cycle - session is in error state');
|
||||||
this.logAction('health', 'Respawn skipped: Session error state');
|
this.logAction('health', 'Respawn skipped: Session error state');
|
||||||
this.emit('respawnBlocked', { reason: 'session_error', details: 'Session is in error state' });
|
this.emit('respawnBlocked', {
|
||||||
|
reason: 'session_error',
|
||||||
|
details: 'Session is in error state',
|
||||||
|
});
|
||||||
this.setState('watching');
|
this.setState('watching');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2907,7 +2970,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.logAction('ralph', `Using RECOMMENDATION: ${rec.substring(0, 50)}...`);
|
this.logAction('ralph', `Using RECOMMENDATION: ${rec.substring(0, 50)}...`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const input = updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI
|
const input = updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI
|
||||||
this.logAction('command', `Sending: "${updatePrompt.substring(0, 50)}..."`);
|
this.logAction('command', `Sending: "${updatePrompt.substring(0, 50)}..."`);
|
||||||
await this.session.writeViaMux(input);
|
await this.session.writeViaMux(input);
|
||||||
this.emit('stepSent', 'update', updatePrompt);
|
this.emit('stepSent', 'update', updatePrompt);
|
||||||
@@ -2937,7 +3000,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.stepTimer = null;
|
this.stepTimer = null;
|
||||||
if (this._state === 'stopped') return;
|
if (this._state === 'stopped') return;
|
||||||
this.logAction('command', 'Sending: /clear');
|
this.logAction('command', 'Sending: /clear');
|
||||||
await this.session.writeViaMux('/clear\r'); // \r triggers Enter in Ink/Claude CLI
|
await this.session.writeViaMux('/clear\r'); // \r triggers Enter in Ink/Claude CLI
|
||||||
this.emit('stepSent', 'clear', '/clear');
|
this.emit('stepSent', 'clear', '/clear');
|
||||||
this.setState('waiting_clear');
|
this.setState('waiting_clear');
|
||||||
this.promptDetected = false;
|
this.promptDetected = false;
|
||||||
@@ -2981,7 +3044,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.stepTimer = null;
|
this.stepTimer = null;
|
||||||
if (this._state === 'stopped') return;
|
if (this._state === 'stopped') return;
|
||||||
this.logAction('command', 'Sending: /init');
|
this.logAction('command', 'Sending: /init');
|
||||||
await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI
|
await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI
|
||||||
this.emit('stepSent', 'init', '/init');
|
this.emit('stepSent', 'init', '/init');
|
||||||
this.setState('waiting_init');
|
this.setState('waiting_init');
|
||||||
this.promptDetected = false;
|
this.promptDetected = false;
|
||||||
@@ -3052,9 +3115,13 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.config = { ...this.config, ...filteredConfig };
|
this.config = { ...this.config, ...filteredConfig };
|
||||||
|
|
||||||
// Sync AI checker config if relevant fields changed
|
// Sync AI checker config if relevant fields changed
|
||||||
if (config.aiIdleCheckEnabled !== undefined || config.aiIdleCheckModel !== undefined ||
|
if (
|
||||||
config.aiIdleCheckMaxContext !== undefined || config.aiIdleCheckTimeoutMs !== undefined ||
|
config.aiIdleCheckEnabled !== undefined ||
|
||||||
config.aiIdleCheckCooldownMs !== undefined) {
|
config.aiIdleCheckModel !== undefined ||
|
||||||
|
config.aiIdleCheckMaxContext !== undefined ||
|
||||||
|
config.aiIdleCheckTimeoutMs !== undefined ||
|
||||||
|
config.aiIdleCheckCooldownMs !== undefined
|
||||||
|
) {
|
||||||
this.aiChecker.updateConfig({
|
this.aiChecker.updateConfig({
|
||||||
enabled: this.config.aiIdleCheckEnabled,
|
enabled: this.config.aiIdleCheckEnabled,
|
||||||
model: this.config.aiIdleCheckModel,
|
model: this.config.aiIdleCheckModel,
|
||||||
@@ -3065,9 +3132,13 @@ export class RespawnController extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sync plan checker config if relevant fields changed
|
// Sync plan checker config if relevant fields changed
|
||||||
if (config.aiPlanCheckEnabled !== undefined || config.aiPlanCheckModel !== undefined ||
|
if (
|
||||||
config.aiPlanCheckMaxContext !== undefined || config.aiPlanCheckTimeoutMs !== undefined ||
|
config.aiPlanCheckEnabled !== undefined ||
|
||||||
config.aiPlanCheckCooldownMs !== undefined) {
|
config.aiPlanCheckModel !== undefined ||
|
||||||
|
config.aiPlanCheckMaxContext !== undefined ||
|
||||||
|
config.aiPlanCheckTimeoutMs !== undefined ||
|
||||||
|
config.aiPlanCheckCooldownMs !== undefined
|
||||||
|
) {
|
||||||
this.planChecker.updateConfig({
|
this.planChecker.updateConfig({
|
||||||
enabled: this.config.aiPlanCheckEnabled,
|
enabled: this.config.aiPlanCheckEnabled,
|
||||||
model: this.config.aiPlanCheckModel,
|
model: this.config.aiPlanCheckModel,
|
||||||
@@ -3276,7 +3347,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const metrics: RespawnCycleMetrics = {
|
const metrics: RespawnCycleMetrics = {
|
||||||
...this.currentCycleMetrics as RespawnCycleMetrics,
|
...(this.currentCycleMetrics as RespawnCycleMetrics),
|
||||||
completedAt: now,
|
completedAt: now,
|
||||||
durationMs: now - (this.currentCycleMetrics.startedAt ?? now),
|
durationMs: now - (this.currentCycleMetrics.startedAt ?? now),
|
||||||
outcome,
|
outcome,
|
||||||
@@ -3299,7 +3370,9 @@ export class RespawnController extends EventEmitter {
|
|||||||
// Clear current cycle
|
// Clear current cycle
|
||||||
this.currentCycleMetrics = null;
|
this.currentCycleMetrics = null;
|
||||||
|
|
||||||
this.log(`Cycle #${metrics.cycleNumber} metrics: ${outcome}, duration=${metrics.durationMs}ms, idle_detection=${metrics.idleDetectionMs}ms`);
|
this.log(
|
||||||
|
`Cycle #${metrics.cycleNumber} metrics: ${outcome}, duration=${metrics.durationMs}ms, idle_detection=${metrics.idleDetectionMs}ms`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3333,8 +3406,8 @@ export class RespawnController extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Recalculate averages using all recent metrics
|
// Recalculate averages using all recent metrics
|
||||||
const durations = this.recentCycleMetrics.map(m => m.durationMs);
|
const durations = this.recentCycleMetrics.map((m) => m.durationMs);
|
||||||
const idleTimes = this.recentCycleMetrics.map(m => m.idleDetectionMs);
|
const idleTimes = this.recentCycleMetrics.map((m) => m.idleDetectionMs);
|
||||||
|
|
||||||
if (durations.length > 0) {
|
if (durations.length > 0) {
|
||||||
agg.avgCycleDurationMs = Math.round(durations.reduce((a, b) => a + b, 0) / durations.length);
|
agg.avgCycleDurationMs = Math.round(durations.reduce((a, b) => a + b, 0) / durations.length);
|
||||||
@@ -3347,9 +3420,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate success rate
|
// Calculate success rate
|
||||||
agg.successRate = agg.totalCycles > 0
|
agg.successRate = agg.totalCycles > 0 ? Math.round((agg.successfulCycles / agg.totalCycles) * 100) : 100;
|
||||||
? Math.round((agg.successfulCycles / agg.totalCycles) * 100)
|
|
||||||
: 100;
|
|
||||||
|
|
||||||
agg.lastUpdatedAt = Date.now();
|
agg.lastUpdatedAt = Date.now();
|
||||||
}
|
}
|
||||||
@@ -3392,18 +3463,18 @@ export class RespawnController extends EventEmitter {
|
|||||||
// Weighted average (cycle success is most important)
|
// Weighted average (cycle success is most important)
|
||||||
const weights = {
|
const weights = {
|
||||||
cycleSuccess: 0.35,
|
cycleSuccess: 0.35,
|
||||||
circuitBreaker: 0.20,
|
circuitBreaker: 0.2,
|
||||||
iterationProgress: 0.20,
|
iterationProgress: 0.2,
|
||||||
aiChecker: 0.15,
|
aiChecker: 0.15,
|
||||||
stuckRecovery: 0.10,
|
stuckRecovery: 0.1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const score = Math.round(
|
const score = Math.round(
|
||||||
components.cycleSuccess * weights.cycleSuccess +
|
components.cycleSuccess * weights.cycleSuccess +
|
||||||
components.circuitBreaker * weights.circuitBreaker +
|
components.circuitBreaker * weights.circuitBreaker +
|
||||||
components.iterationProgress * weights.iterationProgress +
|
components.iterationProgress * weights.iterationProgress +
|
||||||
components.aiChecker * weights.aiChecker +
|
components.aiChecker * weights.aiChecker +
|
||||||
components.stuckRecovery * weights.stuckRecovery
|
components.stuckRecovery * weights.stuckRecovery
|
||||||
);
|
);
|
||||||
|
|
||||||
// Determine status
|
// Determine status
|
||||||
@@ -3446,10 +3517,14 @@ export class RespawnController extends EventEmitter {
|
|||||||
|
|
||||||
const cb = tracker.circuitBreakerStatus;
|
const cb = tracker.circuitBreakerStatus;
|
||||||
switch (cb.state) {
|
switch (cb.state) {
|
||||||
case 'CLOSED': return 100;
|
case 'CLOSED':
|
||||||
case 'HALF_OPEN': return 50;
|
return 100;
|
||||||
case 'OPEN': return 0;
|
case 'HALF_OPEN':
|
||||||
default: return 100;
|
return 50;
|
||||||
|
case 'OPEN':
|
||||||
|
return 0;
|
||||||
|
default:
|
||||||
|
return 100;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3527,8 +3602,10 @@ export class RespawnController extends EventEmitter {
|
|||||||
status: HealthStatus,
|
status: HealthStatus,
|
||||||
components: RalphLoopHealthScore['components']
|
components: RalphLoopHealthScore['components']
|
||||||
): string {
|
): string {
|
||||||
const lowest = Object.entries(components).reduce((min, [key, val]) =>
|
const lowest = Object.entries(components).reduce((min, [key, val]) => (val < min.val ? { key, val } : min), {
|
||||||
val < min.val ? { key, val } : min, { key: '', val: 100 });
|
key: '',
|
||||||
|
val: 100,
|
||||||
|
});
|
||||||
|
|
||||||
if (status === 'excellent') {
|
if (status === 'excellent') {
|
||||||
return `Ralph Loop is operating excellently (${score}/100). All systems healthy.`;
|
return `Ralph Loop is operating excellently (${score}/100). All systems healthy.`;
|
||||||
|
|||||||
+17
-25
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
+179
-99
@@ -18,19 +18,26 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import * as pty from 'node-pty';
|
import * as pty from 'node-pty';
|
||||||
import { SessionState, SessionStatus, SessionConfig, RalphTrackerState, RalphTodoItem, ActiveBashTool, NiceConfig, DEFAULT_NICE_CONFIG, type ClaudeMode, type SessionMode, type OpenCodeConfig } from './types.js';
|
import {
|
||||||
|
SessionState,
|
||||||
|
SessionStatus,
|
||||||
|
SessionConfig,
|
||||||
|
RalphTrackerState,
|
||||||
|
RalphTodoItem,
|
||||||
|
ActiveBashTool,
|
||||||
|
NiceConfig,
|
||||||
|
DEFAULT_NICE_CONFIG,
|
||||||
|
type ClaudeMode,
|
||||||
|
type SessionMode,
|
||||||
|
type OpenCodeConfig,
|
||||||
|
} from './types.js';
|
||||||
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
||||||
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
||||||
import { RalphTracker } from './ralph-tracker.js';
|
import { RalphTracker } from './ralph-tracker.js';
|
||||||
import { BashToolParser } from './bash-tool-parser.js';
|
import { BashToolParser } from './bash-tool-parser.js';
|
||||||
import { BufferAccumulator } from './utils/buffer-accumulator.js';
|
import { BufferAccumulator } from './utils/buffer-accumulator.js';
|
||||||
import { LRUMap } from './utils/lru-map.js';
|
import { LRUMap } from './utils/lru-map.js';
|
||||||
import {
|
import { ANSI_ESCAPE_PATTERN_FULL, TOKEN_PATTERN, SPINNER_PATTERN, MAX_SESSION_TOKENS } from './utils/index.js';
|
||||||
ANSI_ESCAPE_PATTERN_FULL,
|
|
||||||
TOKEN_PATTERN,
|
|
||||||
SPINNER_PATTERN,
|
|
||||||
MAX_SESSION_TOKENS,
|
|
||||||
} from './utils/index.js';
|
|
||||||
import {
|
import {
|
||||||
MAX_TERMINAL_BUFFER_SIZE,
|
MAX_TERMINAL_BUFFER_SIZE,
|
||||||
TRIM_TERMINAL_TO as TERMINAL_BUFFER_TRIM_SIZE,
|
TRIM_TERMINAL_TO as TERMINAL_BUFFER_TRIM_SIZE,
|
||||||
@@ -68,6 +75,7 @@ const GRACEFUL_SHUTDOWN_DELAY_MS = 100;
|
|||||||
|
|
||||||
// Filter out terminal focus escape sequences (focus in/out reports)
|
// Filter out terminal focus escape sequences (focus in/out reports)
|
||||||
// ^[[I (focus in), ^[[O (focus out), and the enable/disable sequences
|
// ^[[I (focus in), ^[[O (focus out), and the enable/disable sequences
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
const FOCUS_ESCAPE_FILTER = /\x1b\[\?1004[hl]|\x1b\[[IO]/g;
|
const FOCUS_ESCAPE_FILTER = /\x1b\[\?1004[hl]|\x1b\[[IO]/g;
|
||||||
|
|
||||||
// Pattern to match Task tool invocations in terminal output
|
// Pattern to match Task tool invocations in terminal output
|
||||||
@@ -78,8 +86,10 @@ const TASK_TOOL_PATTERN = /\b(Explore|Task|Bash|Plan|general-purpose)\(([^)]+)\)
|
|||||||
|
|
||||||
// Pre-compiled patterns for hot paths (avoid regex compilation per call)
|
// Pre-compiled patterns for hot paths (avoid regex compilation per call)
|
||||||
/** Pattern to strip leading ANSI escapes and whitespace from terminal buffer */
|
/** Pattern to strip leading ANSI escapes and whitespace from terminal buffer */
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
const LEADING_ANSI_WHITESPACE_PATTERN = /^(\x1b\[\??[\d;]*[A-Za-z]|[\s\r\n])+/;
|
const LEADING_ANSI_WHITESPACE_PATTERN = /^(\x1b\[\??[\d;]*[A-Za-z]|[\s\r\n])+/;
|
||||||
/** Pattern to match Ctrl+L (form feed) characters */
|
/** Pattern to match Ctrl+L (form feed) characters */
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
const CTRL_L_PATTERN = /\x0c/g;
|
const CTRL_L_PATTERN = /\x0c/g;
|
||||||
/** Pattern to split by newlines (CR or LF) */
|
/** Pattern to split by newlines (CR or LF) */
|
||||||
const NEWLINE_SPLIT_PATTERN = /\r?\n/;
|
const NEWLINE_SPLIT_PATTERN = /\r?\n/;
|
||||||
@@ -96,11 +106,7 @@ import { getAugmentedPath } from './utils/claude-cli-resolver.js';
|
|||||||
* @param operation - Description of the operation for error messages
|
* @param operation - Description of the operation for error messages
|
||||||
* @returns Promise that resolves/rejects with the original result or timeout error
|
* @returns Promise that resolves/rejects with the original result or timeout error
|
||||||
*/
|
*/
|
||||||
function withTimeout<T>(
|
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, operation: string): Promise<T> {
|
||||||
promise: Promise<T>,
|
|
||||||
timeoutMs: number,
|
|
||||||
operation: string
|
|
||||||
): Promise<T> {
|
|
||||||
let timeoutId: NodeJS.Timeout;
|
let timeoutId: NodeJS.Timeout;
|
||||||
|
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
@@ -193,7 +199,12 @@ export interface SessionEvents {
|
|||||||
/** Active Bash tools list updated */
|
/** Active Bash tools list updated */
|
||||||
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
|
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
|
||||||
/** CLI info (version, model, account) updated */
|
/** CLI info (version, model, account) updated */
|
||||||
cliInfoUpdated: (info: { version: string | null; model: string | null; accountType: string | null; latestVersion: string | null }) => void;
|
cliInfoUpdated: (info: {
|
||||||
|
version: string | null;
|
||||||
|
model: string | null;
|
||||||
|
accountType: string | null;
|
||||||
|
latestVersion: string | null;
|
||||||
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionMode is imported from types.ts (single source of truth)
|
// SessionMode is imported from types.ts (single source of truth)
|
||||||
@@ -258,7 +269,7 @@ export class Session extends EventEmitter {
|
|||||||
private _lineBufferFlushTimer: NodeJS.Timeout | null = null;
|
private _lineBufferFlushTimer: NodeJS.Timeout | null = null;
|
||||||
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
|
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
|
||||||
private rejectPromise: ((reason: Error) => void) | null = null;
|
private rejectPromise: ((reason: Error) => void) | null = null;
|
||||||
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
|
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
|
||||||
private _isWorking: boolean = false;
|
private _isWorking: boolean = false;
|
||||||
private _lastPromptTime: number = 0;
|
private _lastPromptTime: number = 0;
|
||||||
private activityTimeout: NodeJS.Timeout | null = null;
|
private activityTimeout: NodeJS.Timeout | null = null;
|
||||||
@@ -356,7 +367,9 @@ export class Session extends EventEmitter {
|
|||||||
// Task descriptions parsed from terminal output (e.g., "Explore(Description)")
|
// Task descriptions parsed from terminal output (e.g., "Explore(Description)")
|
||||||
// Used to correlate with SubagentWatcher discoveries for better window titles
|
// Used to correlate with SubagentWatcher discoveries for better window titles
|
||||||
// Uses LRUMap for automatic eviction at MAX_TASK_DESCRIPTIONS limit
|
// Uses LRUMap for automatic eviction at MAX_TASK_DESCRIPTIONS limit
|
||||||
private _recentTaskDescriptions: LRUMap<number, string> = new LRUMap({ maxSize: Session.MAX_TASK_DESCRIPTIONS });
|
private _recentTaskDescriptions: LRUMap<number, string> = new LRUMap({
|
||||||
|
maxSize: Session.MAX_TASK_DESCRIPTIONS,
|
||||||
|
});
|
||||||
|
|
||||||
// Throttle expensive PTY processing (Ralph, bash parser, task descriptions)
|
// Throttle expensive PTY processing (Ralph, bash parser, task descriptions)
|
||||||
// Accumulates clean data between processing windows to avoid running regex on every chunk
|
// Accumulates clean data between processing windows to avoid running regex on every chunk
|
||||||
@@ -365,26 +378,28 @@ export class Session extends EventEmitter {
|
|||||||
private _expensiveProcessTimer: NodeJS.Timeout | null = null;
|
private _expensiveProcessTimer: NodeJS.Timeout | null = null;
|
||||||
private static readonly EXPENSIVE_PROCESS_INTERVAL_MS = 150; // Process at most every 150ms
|
private static readonly EXPENSIVE_PROCESS_INTERVAL_MS = 150; // Process at most every 150ms
|
||||||
|
|
||||||
constructor(config: Partial<SessionConfig> & {
|
constructor(
|
||||||
workingDir: string;
|
config: Partial<SessionConfig> & {
|
||||||
mode?: SessionMode;
|
workingDir: string;
|
||||||
name?: string;
|
mode?: SessionMode;
|
||||||
/** Terminal multiplexer instance (tmux) */
|
name?: string;
|
||||||
mux?: TerminalMultiplexer;
|
/** Terminal multiplexer instance (tmux) */
|
||||||
/** Whether to use multiplexer wrapping */
|
mux?: TerminalMultiplexer;
|
||||||
useMux?: boolean;
|
/** Whether to use multiplexer wrapping */
|
||||||
/** Existing mux session for restored sessions */
|
useMux?: boolean;
|
||||||
muxSession?: MuxSession;
|
/** Existing mux session for restored sessions */
|
||||||
niceConfig?: NiceConfig; // Nice prioritying configuration
|
muxSession?: MuxSession;
|
||||||
/** Claude model override (e.g., 'opus', 'sonnet', 'haiku') */
|
niceConfig?: NiceConfig; // Nice prioritying configuration
|
||||||
model?: string;
|
/** Claude model override (e.g., 'opus', 'sonnet', 'haiku') */
|
||||||
/** Claude CLI startup permission mode */
|
model?: string;
|
||||||
claudeMode?: ClaudeMode;
|
/** Claude CLI startup permission mode */
|
||||||
/** Comma-separated allowed tools (for 'allowedTools' mode) */
|
claudeMode?: ClaudeMode;
|
||||||
allowedTools?: string;
|
/** Comma-separated allowed tools (for 'allowedTools' mode) */
|
||||||
/** OpenCode configuration (only for mode === 'opencode') */
|
allowedTools?: string;
|
||||||
openCodeConfig?: OpenCodeConfig;
|
/** OpenCode configuration (only for mode === 'opencode') */
|
||||||
}) {
|
openCodeConfig?: OpenCodeConfig;
|
||||||
|
}
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
this.setMaxListeners(25);
|
this.setMaxListeners(25);
|
||||||
|
|
||||||
@@ -472,7 +487,6 @@ export class Session extends EventEmitter {
|
|||||||
this._bashToolParser.on('toolStart', this._bashToolHandlers.toolStart);
|
this._bashToolParser.on('toolStart', this._bashToolHandlers.toolStart);
|
||||||
this._bashToolParser.on('toolEnd', this._bashToolHandlers.toolEnd);
|
this._bashToolParser.on('toolEnd', this._bashToolHandlers.toolEnd);
|
||||||
this._bashToolParser.on('toolsUpdate', this._bashToolHandlers.toolsUpdate);
|
this._bashToolParser.on('toolsUpdate', this._bashToolHandlers.toolsUpdate);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get status(): SessionStatus {
|
get status(): SessionStatus {
|
||||||
@@ -673,17 +687,23 @@ export class Session extends EventEmitter {
|
|||||||
restoreTokens(inputTokens: number, outputTokens: number, totalCost: number): void {
|
restoreTokens(inputTokens: number, outputTokens: number, totalCost: number): void {
|
||||||
// Sanity check: reject absurdly large individual values
|
// Sanity check: reject absurdly large individual values
|
||||||
if (inputTokens > MAX_SESSION_TOKENS || outputTokens > MAX_SESSION_TOKENS) {
|
if (inputTokens > MAX_SESSION_TOKENS || outputTokens > MAX_SESSION_TOKENS) {
|
||||||
console.warn(`[Session ${this.id}] Rejected absurd restored tokens: input=${inputTokens}, output=${outputTokens}`);
|
console.warn(
|
||||||
|
`[Session ${this.id}] Rejected absurd restored tokens: input=${inputTokens}, output=${outputTokens}`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Check token sum doesn't overflow MAX_SESSION_TOKENS
|
// Check token sum doesn't overflow MAX_SESSION_TOKENS
|
||||||
if (inputTokens + outputTokens > MAX_SESSION_TOKENS) {
|
if (inputTokens + outputTokens > MAX_SESSION_TOKENS) {
|
||||||
console.warn(`[Session ${this.id}] Rejected token sum overflow: input=${inputTokens} + output=${outputTokens} = ${inputTokens + outputTokens} > ${MAX_SESSION_TOKENS}`);
|
console.warn(
|
||||||
|
`[Session ${this.id}] Rejected token sum overflow: input=${inputTokens} + output=${outputTokens} = ${inputTokens + outputTokens} > ${MAX_SESSION_TOKENS}`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Reject negative values
|
// Reject negative values
|
||||||
if (inputTokens < 0 || outputTokens < 0 || totalCost < 0) {
|
if (inputTokens < 0 || outputTokens < 0 || totalCost < 0) {
|
||||||
console.warn(`[Session ${this.id}] Rejected negative restored tokens: input=${inputTokens}, output=${outputTokens}, cost=${totalCost}`);
|
console.warn(
|
||||||
|
`[Session ${this.id}] Rejected negative restored tokens: input=${inputTokens}, output=${outputTokens}, cost=${totalCost}`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -722,7 +742,9 @@ export class Session extends EventEmitter {
|
|||||||
if (threshold !== undefined) {
|
if (threshold !== undefined) {
|
||||||
// Validate threshold bounds
|
// Validate threshold bounds
|
||||||
if (threshold < Session.MIN_AUTO_THRESHOLD || threshold > Session.MAX_AUTO_THRESHOLD) {
|
if (threshold < Session.MIN_AUTO_THRESHOLD || threshold > Session.MAX_AUTO_THRESHOLD) {
|
||||||
console.warn(`[Session ${this.id}] Invalid autoClear threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_CLEAR_THRESHOLD}.`);
|
console.warn(
|
||||||
|
`[Session ${this.id}] Invalid autoClear threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_CLEAR_THRESHOLD}.`
|
||||||
|
);
|
||||||
this._autoClearThreshold = Session.DEFAULT_AUTO_CLEAR_THRESHOLD;
|
this._autoClearThreshold = Session.DEFAULT_AUTO_CLEAR_THRESHOLD;
|
||||||
} else {
|
} else {
|
||||||
this._autoClearThreshold = threshold;
|
this._autoClearThreshold = threshold;
|
||||||
@@ -747,7 +769,9 @@ export class Session extends EventEmitter {
|
|||||||
if (threshold !== undefined) {
|
if (threshold !== undefined) {
|
||||||
// Validate threshold bounds
|
// Validate threshold bounds
|
||||||
if (threshold < Session.MIN_AUTO_THRESHOLD || threshold > Session.MAX_AUTO_THRESHOLD) {
|
if (threshold < Session.MIN_AUTO_THRESHOLD || threshold > Session.MAX_AUTO_THRESHOLD) {
|
||||||
console.warn(`[Session ${this.id}] Invalid autoCompact threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_COMPACT_THRESHOLD}.`);
|
console.warn(
|
||||||
|
`[Session ${this.id}] Invalid autoCompact threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_COMPACT_THRESHOLD}.`
|
||||||
|
);
|
||||||
this._autoCompactThreshold = Session.DEFAULT_AUTO_COMPACT_THRESHOLD;
|
this._autoCompactThreshold = Session.DEFAULT_AUTO_COMPACT_THRESHOLD;
|
||||||
} else {
|
} else {
|
||||||
this._autoCompactThreshold = threshold;
|
this._autoCompactThreshold = threshold;
|
||||||
@@ -911,7 +935,9 @@ export class Session extends EventEmitter {
|
|||||||
this._lastActivityAt = Date.now();
|
this._lastActivityAt = Date.now();
|
||||||
|
|
||||||
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : 'Claude';
|
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : 'Claude';
|
||||||
console.log(`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : ''));
|
console.log(
|
||||||
|
`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '')
|
||||||
|
);
|
||||||
|
|
||||||
// If mux wrapping is enabled, create or attach to a mux session
|
// If mux wrapping is enabled, create or attach to a mux session
|
||||||
if (this._useMux && this._mux) {
|
if (this._useMux && this._mux) {
|
||||||
@@ -928,16 +954,21 @@ export class Session extends EventEmitter {
|
|||||||
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
|
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
|
||||||
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
||||||
const newPid = await this._mux.respawnPane({
|
const newPid = await this._mux.respawnPane({
|
||||||
sessionId: this.id, workingDir: this.workingDir, mode: this.mode,
|
sessionId: this.id,
|
||||||
niceConfig: this._niceConfig, model: this._model, claudeMode: this._claudeMode,
|
workingDir: this.workingDir,
|
||||||
allowedTools: this._allowedTools, openCodeConfig: this._openCodeConfig,
|
mode: this.mode,
|
||||||
|
niceConfig: this._niceConfig,
|
||||||
|
model: this._model,
|
||||||
|
claudeMode: this._claudeMode,
|
||||||
|
allowedTools: this._allowedTools,
|
||||||
|
openCodeConfig: this._openCodeConfig,
|
||||||
});
|
});
|
||||||
if (!newPid) {
|
if (!newPid) {
|
||||||
console.error('[Session] Failed to respawn pane, will create new session');
|
console.error('[Session] Failed to respawn pane, will create new session');
|
||||||
needsNewSession = true;
|
needsNewSession = true;
|
||||||
} else {
|
} else {
|
||||||
// Wait a moment for the respawned process to fully start
|
// Wait a moment for the respawned process to fully start
|
||||||
await new Promise(resolve => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -948,9 +979,14 @@ export class Session extends EventEmitter {
|
|||||||
} else {
|
} else {
|
||||||
// Create a new mux session
|
// Create a new mux session
|
||||||
this._muxSession = await this._mux.createSession({
|
this._muxSession = await this._mux.createSession({
|
||||||
sessionId: this.id, workingDir: this.workingDir, mode: this.mode,
|
sessionId: this.id,
|
||||||
name: this._name, niceConfig: this._niceConfig, model: this._model,
|
workingDir: this.workingDir,
|
||||||
claudeMode: this._claudeMode, allowedTools: this._allowedTools,
|
mode: this.mode,
|
||||||
|
name: this._name,
|
||||||
|
niceConfig: this._niceConfig,
|
||||||
|
model: this._model,
|
||||||
|
claudeMode: this._claudeMode,
|
||||||
|
allowedTools: this._allowedTools,
|
||||||
openCodeConfig: this._openCodeConfig,
|
openCodeConfig: this._openCodeConfig,
|
||||||
});
|
});
|
||||||
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
||||||
@@ -962,13 +998,21 @@ export class Session extends EventEmitter {
|
|||||||
this.ptyProcess = pty.spawn(
|
this.ptyProcess = pty.spawn(
|
||||||
this._mux.getAttachCommand(),
|
this._mux.getAttachCommand(),
|
||||||
this._mux.getAttachArgs(this._muxSession!.muxName),
|
this._mux.getAttachArgs(this._muxSession!.muxName),
|
||||||
{
|
{
|
||||||
name: 'xterm-256color',
|
name: 'xterm-256color',
|
||||||
cols: 120,
|
cols: 120,
|
||||||
rows: 40,
|
rows: 40,
|
||||||
cwd: this.workingDir,
|
cwd: this.workingDir,
|
||||||
env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', TERM: 'xterm-256color', COLORTERM: undefined, CLAUDECODE: undefined },
|
env: {
|
||||||
});
|
...process.env,
|
||||||
|
LANG: 'en_US.UTF-8',
|
||||||
|
LC_ALL: 'en_US.UTF-8',
|
||||||
|
TERM: 'xterm-256color',
|
||||||
|
COLORTERM: undefined,
|
||||||
|
CLAUDECODE: undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Set claudeSessionId immediately since we passed --session-id to Claude
|
// Set claudeSessionId immediately since we passed --session-id to Claude
|
||||||
// The mux manager passes --session-id ${sessionId} to Claude
|
// The mux manager passes --session-id ${sessionId} to Claude
|
||||||
@@ -1010,9 +1054,7 @@ export class Session extends EventEmitter {
|
|||||||
// Clean the buffer - remove mux init junk before actual content
|
// Clean the buffer - remove mux init junk before actual content
|
||||||
// Strip: cursor movement (\x1b[nA/B/C/D), positioning (\x1b[n;nH),
|
// Strip: cursor movement (\x1b[nA/B/C/D), positioning (\x1b[n;nH),
|
||||||
// clear screen (\x1b[2J), scroll region (\x1b[n;nr), and whitespace
|
// clear screen (\x1b[2J), scroll region (\x1b[n;nr), and whitespace
|
||||||
this._terminalBuffer.set(
|
this._terminalBuffer.set(bufferValue.replace(LEADING_ANSI_WHITESPACE_PATTERN, ''));
|
||||||
bufferValue.replace(LEADING_ANSI_WHITESPACE_PATTERN, '')
|
|
||||||
);
|
|
||||||
// Signal client to refresh
|
// Signal client to refresh
|
||||||
this.emit('clearTerminal');
|
this.emit('clearTerminal');
|
||||||
}
|
}
|
||||||
@@ -1081,9 +1123,7 @@ export class Session extends EventEmitter {
|
|||||||
|
|
||||||
this.ptyProcess.onData((rawData: string) => {
|
this.ptyProcess.onData((rawData: string) => {
|
||||||
// Filter out focus escape sequences and Ctrl+L (form feed)
|
// Filter out focus escape sequences and Ctrl+L (form feed)
|
||||||
const data = rawData
|
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '').replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L
|
||||||
.replace(FOCUS_ESCAPE_FILTER, '')
|
|
||||||
.replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L
|
|
||||||
if (!data) return; // Skip if only filtered sequences
|
if (!data) return; // Skip if only filtered sequences
|
||||||
|
|
||||||
// BufferAccumulator handles auto-trimming when max size exceeded
|
// BufferAccumulator handles auto-trimming when max size exceeded
|
||||||
@@ -1252,8 +1292,12 @@ export class Session extends EventEmitter {
|
|||||||
// Only check if spinner didn't already trigger working state
|
// Only check if spinner didn't already trigger working state
|
||||||
if (!this._isWorking) {
|
if (!this._isWorking) {
|
||||||
const cleanData = getCleanData();
|
const cleanData = getCleanData();
|
||||||
if (cleanData.includes('Thinking') || cleanData.includes('Writing') ||
|
if (
|
||||||
cleanData.includes('Reading') || cleanData.includes('Running')) {
|
cleanData.includes('Thinking') ||
|
||||||
|
cleanData.includes('Writing') ||
|
||||||
|
cleanData.includes('Reading') ||
|
||||||
|
cleanData.includes('Running')
|
||||||
|
) {
|
||||||
this._isWorking = true;
|
this._isWorking = true;
|
||||||
this._status = 'busy';
|
this._status = 'busy';
|
||||||
this.emit('working');
|
this.emit('working');
|
||||||
@@ -1293,7 +1337,10 @@ export class Session extends EventEmitter {
|
|||||||
|
|
||||||
// Use user's default shell or bash
|
// Use user's default shell or bash
|
||||||
const shell = process.env.SHELL || '/bin/bash';
|
const shell = process.env.SHELL || '/bin/bash';
|
||||||
console.log('[Session] Starting shell session with:', shell + (this._useMux ? ` (with ${this._mux!.backend})` : ''));
|
console.log(
|
||||||
|
'[Session] Starting shell session with:',
|
||||||
|
shell + (this._useMux ? ` (with ${this._mux!.backend})` : '')
|
||||||
|
);
|
||||||
|
|
||||||
// If mux wrapping is enabled, create or attach to a mux session
|
// If mux wrapping is enabled, create or attach to a mux session
|
||||||
if (this._useMux && this._mux) {
|
if (this._useMux && this._mux) {
|
||||||
@@ -1308,12 +1355,17 @@ export class Session extends EventEmitter {
|
|||||||
let needsNewSession = false;
|
let needsNewSession = false;
|
||||||
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
|
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
|
||||||
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
||||||
const newPid = await this._mux.respawnPane({ sessionId: this.id, workingDir: this.workingDir, mode: 'shell', niceConfig: this._niceConfig });
|
const newPid = await this._mux.respawnPane({
|
||||||
|
sessionId: this.id,
|
||||||
|
workingDir: this.workingDir,
|
||||||
|
mode: 'shell',
|
||||||
|
niceConfig: this._niceConfig,
|
||||||
|
});
|
||||||
if (!newPid) {
|
if (!newPid) {
|
||||||
console.error('[Session] Failed to respawn pane, will create new session');
|
console.error('[Session] Failed to respawn pane, will create new session');
|
||||||
needsNewSession = true;
|
needsNewSession = true;
|
||||||
} else {
|
} else {
|
||||||
await new Promise(resolve => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1323,7 +1375,13 @@ export class Session extends EventEmitter {
|
|||||||
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
||||||
} else {
|
} else {
|
||||||
// Create a new mux session
|
// Create a new mux session
|
||||||
this._muxSession = await this._mux.createSession({ sessionId: this.id, workingDir: this.workingDir, mode: 'shell', name: this._name, niceConfig: this._niceConfig });
|
this._muxSession = await this._mux.createSession({
|
||||||
|
sessionId: this.id,
|
||||||
|
workingDir: this.workingDir,
|
||||||
|
mode: 'shell',
|
||||||
|
name: this._name,
|
||||||
|
niceConfig: this._niceConfig,
|
||||||
|
});
|
||||||
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
||||||
// No extra sleep — createSession() already waits for tmux readiness
|
// No extra sleep — createSession() already waits for tmux readiness
|
||||||
}
|
}
|
||||||
@@ -1333,13 +1391,21 @@ export class Session extends EventEmitter {
|
|||||||
this.ptyProcess = pty.spawn(
|
this.ptyProcess = pty.spawn(
|
||||||
this._mux.getAttachCommand(),
|
this._mux.getAttachCommand(),
|
||||||
this._mux.getAttachArgs(this._muxSession!.muxName),
|
this._mux.getAttachArgs(this._muxSession!.muxName),
|
||||||
{
|
{
|
||||||
name: 'xterm-256color',
|
name: 'xterm-256color',
|
||||||
cols: 120,
|
cols: 120,
|
||||||
rows: 40,
|
rows: 40,
|
||||||
cwd: this.workingDir,
|
cwd: this.workingDir,
|
||||||
env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', TERM: 'xterm-256color', COLORTERM: undefined, CLAUDECODE: undefined },
|
env: {
|
||||||
});
|
...process.env,
|
||||||
|
LANG: 'en_US.UTF-8',
|
||||||
|
LC_ALL: 'en_US.UTF-8',
|
||||||
|
TERM: 'xterm-256color',
|
||||||
|
COLORTERM: undefined,
|
||||||
|
CLAUDECODE: undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
} catch (spawnErr) {
|
} catch (spawnErr) {
|
||||||
console.error('[Session] Failed to spawn PTY for shell mux attachment:', spawnErr);
|
console.error('[Session] Failed to spawn PTY for shell mux attachment:', spawnErr);
|
||||||
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
|
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
|
||||||
@@ -1474,7 +1540,7 @@ export class Session extends EventEmitter {
|
|||||||
this._messages = [];
|
this._messages = [];
|
||||||
this._lineBuffer = '';
|
this._lineBuffer = '';
|
||||||
this._lastActivityAt = Date.now();
|
this._lastActivityAt = Date.now();
|
||||||
this._promptResolved = false; // Reset race condition guard
|
this._promptResolved = false; // Reset race condition guard
|
||||||
|
|
||||||
this.resolvePromise = resolve;
|
this.resolvePromise = resolve;
|
||||||
this.rejectPromise = reject;
|
this.rejectPromise = reject;
|
||||||
@@ -1482,14 +1548,13 @@ export class Session extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
// Spawn claude in a real PTY
|
// Spawn claude in a real PTY
|
||||||
const model = options?.model;
|
const model = options?.model;
|
||||||
console.log('[Session] Spawning PTY for claude with prompt:', prompt.substring(0, 50), model ? `(model: ${model})` : '');
|
console.log(
|
||||||
|
'[Session] Spawning PTY for claude with prompt:',
|
||||||
|
prompt.substring(0, 50),
|
||||||
|
model ? `(model: ${model})` : ''
|
||||||
|
);
|
||||||
|
|
||||||
const args = [
|
const args = ['-p', '--verbose', '--dangerously-skip-permissions', '--output-format', 'stream-json'];
|
||||||
'-p',
|
|
||||||
'--verbose',
|
|
||||||
'--dangerously-skip-permissions',
|
|
||||||
'--output-format', 'stream-json',
|
|
||||||
];
|
|
||||||
if (model) {
|
if (model) {
|
||||||
args.push('--model', model);
|
args.push('--model', model);
|
||||||
}
|
}
|
||||||
@@ -1517,7 +1582,10 @@ export class Session extends EventEmitter {
|
|||||||
});
|
});
|
||||||
} catch (spawnErr) {
|
} catch (spawnErr) {
|
||||||
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
|
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
|
||||||
this.emit('error', `Failed to spawn Claude: ${spawnErr instanceof Error ? spawnErr.message : String(spawnErr)}`);
|
this.emit(
|
||||||
|
'error',
|
||||||
|
`Failed to spawn Claude: ${spawnErr instanceof Error ? spawnErr.message : String(spawnErr)}`
|
||||||
|
);
|
||||||
throw spawnErr;
|
throw spawnErr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1561,7 +1629,7 @@ export class Session extends EventEmitter {
|
|||||||
this.rejectPromise = null;
|
this.rejectPromise = null;
|
||||||
|
|
||||||
// Find result from parsed messages or use text output
|
// Find result from parsed messages or use text output
|
||||||
const resultMsg = this._messages.find(m => m.type === 'result');
|
const resultMsg = this._messages.find((m) => m.type === 'result');
|
||||||
|
|
||||||
if (resultMsg && !resultMsg.is_error) {
|
if (resultMsg && !resultMsg.is_error) {
|
||||||
this._status = 'idle';
|
this._status = 'idle';
|
||||||
@@ -1579,13 +1647,15 @@ export class Session extends EventEmitter {
|
|||||||
} else {
|
} else {
|
||||||
this._status = 'idle';
|
this._status = 'idle';
|
||||||
if (resolve) {
|
if (resolve) {
|
||||||
resolve({ result: this._textOutput.value || this._terminalBuffer.value, cost: this._totalCost });
|
resolve({
|
||||||
|
result: this._textOutput.value || this._terminalBuffer.value,
|
||||||
|
cost: this._totalCost,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('exit', exitCode);
|
this.emit('exit', exitCode);
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._status = 'error';
|
this._status = 'error';
|
||||||
reject(err);
|
reject(err);
|
||||||
@@ -1649,8 +1719,8 @@ export class Session extends EventEmitter {
|
|||||||
|
|
||||||
// Extract Claude session ID from messages (can be in any message type)
|
// Extract Claude session ID from messages (can be in any message type)
|
||||||
// Support both sessionId (camelCase) and session_id (snake_case)
|
// Support both sessionId (camelCase) and session_id (snake_case)
|
||||||
const msgSessionId = (msg as unknown as Record<string, unknown>).sessionId as string | undefined
|
const msgSessionId =
|
||||||
?? msg.session_id;
|
((msg as unknown as Record<string, unknown>).sessionId as string | undefined) ?? msg.session_id;
|
||||||
if (msgSessionId && !this._claudeSessionId) {
|
if (msgSessionId && !this._claudeSessionId) {
|
||||||
this._claudeSessionId = msgSessionId;
|
this._claudeSessionId = msgSessionId;
|
||||||
}
|
}
|
||||||
@@ -1689,7 +1759,10 @@ export class Session extends EventEmitter {
|
|||||||
}
|
}
|
||||||
} catch (parseErr) {
|
} catch (parseErr) {
|
||||||
// Not JSON, just regular output - this is expected for non-JSON lines
|
// Not JSON, just regular output - this is expected for non-JSON lines
|
||||||
console.debug('[Session] Line not JSON (expected for text output):', parseErr instanceof Error ? parseErr.message : parseErr);
|
console.debug(
|
||||||
|
'[Session] Line not JSON (expected for text output):',
|
||||||
|
parseErr instanceof Error ? parseErr.message : parseErr
|
||||||
|
);
|
||||||
this._textOutput.append(line + '\n');
|
this._textOutput.append(line + '\n');
|
||||||
}
|
}
|
||||||
} else if (trimmed) {
|
} else if (trimmed) {
|
||||||
@@ -1829,7 +1902,9 @@ export class Session extends EventEmitter {
|
|||||||
// Safety: Reject M values that would result in > 500k tokens
|
// Safety: Reject M values that would result in > 500k tokens
|
||||||
// Claude's context window is ~200k, so anything claiming millions is likely a false match
|
// Claude's context window is ~200k, so anything claiming millions is likely a false match
|
||||||
if (tokenCount > 0.5) {
|
if (tokenCount > 0.5) {
|
||||||
console.warn(`[Session ${this.id}] Rejected suspicious M token value: ${tokenMatch[0]} (would be ${tokenCount * 1000000} tokens)`);
|
console.warn(
|
||||||
|
`[Session ${this.id}] Rejected suspicious M token value: ${tokenMatch[0]} (would be ${tokenCount * 1000000} tokens)`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tokenCount *= 1000000;
|
tokenCount *= 1000000;
|
||||||
@@ -1850,7 +1925,9 @@ export class Session extends EventEmitter {
|
|||||||
// Safety: Reject suspiciously large jumps (max 100k per update)
|
// Safety: Reject suspiciously large jumps (max 100k per update)
|
||||||
const MAX_DELTA_PER_UPDATE = 100_000;
|
const MAX_DELTA_PER_UPDATE = 100_000;
|
||||||
if (delta > MAX_DELTA_PER_UPDATE) {
|
if (delta > MAX_DELTA_PER_UPDATE) {
|
||||||
console.warn(`[Session ${this.id}] Rejected suspicious token jump: ${currentTotal} -> ${tokenCount} (delta: ${delta})`);
|
console.warn(
|
||||||
|
`[Session ${this.id}] Rejected suspicious token jump: ${currentTotal} -> ${tokenCount} (delta: ${delta})`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1874,7 +1951,12 @@ export class Session extends EventEmitter {
|
|||||||
if (this._cliInfoParsed) return;
|
if (this._cliInfoParsed) return;
|
||||||
|
|
||||||
// Quick pre-checks
|
// Quick pre-checks
|
||||||
if (!cleanData.includes('Claude') && !cleanData.includes('current:') && !cleanData.includes('Opus') && !cleanData.includes('Sonnet')) {
|
if (
|
||||||
|
!cleanData.includes('Claude') &&
|
||||||
|
!cleanData.includes('current:') &&
|
||||||
|
!cleanData.includes('Opus') &&
|
||||||
|
!cleanData.includes('Sonnet')
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let changed = false;
|
let changed = false;
|
||||||
@@ -1960,14 +2042,12 @@ export class Session extends EventEmitter {
|
|||||||
if (this._isStopped) return;
|
if (this._isStopped) return;
|
||||||
|
|
||||||
// Send /compact command with optional prompt
|
// Send /compact command with optional prompt
|
||||||
const compactCmd = this._autoCompactPrompt
|
const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r';
|
||||||
? `/compact ${this._autoCompactPrompt}\r`
|
|
||||||
: '/compact\r';
|
|
||||||
await this.writeViaMux(compactCmd);
|
await this.writeViaMux(compactCmd);
|
||||||
this.emit('autoCompact', {
|
this.emit('autoCompact', {
|
||||||
tokens: totalTokens,
|
tokens: totalTokens,
|
||||||
threshold: this._autoCompactThreshold,
|
threshold: this._autoCompactThreshold,
|
||||||
prompt: this._autoCompactPrompt || undefined
|
prompt: this._autoCompactPrompt || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait a moment then re-enable (longer than clear since compact takes time)
|
// Wait a moment then re-enable (longer than clear since compact takes time)
|
||||||
@@ -2121,7 +2201,7 @@ export class Session extends EventEmitter {
|
|||||||
async sendInput(input: string): Promise<void> {
|
async sendInput(input: string): Promise<void> {
|
||||||
this._status = 'busy';
|
this._status = 'busy';
|
||||||
this._lastActivityAt = Date.now();
|
this._lastActivityAt = Date.now();
|
||||||
this.runPrompt(input).catch(err => {
|
this.runPrompt(input).catch((err) => {
|
||||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
this.emit('error', errorMsg);
|
this.emit('error', errorMsg);
|
||||||
});
|
});
|
||||||
@@ -2260,7 +2340,7 @@ export class Session extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Give it a moment to terminate gracefully
|
// Give it a moment to terminate gracefully
|
||||||
await new Promise(resolve => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS));
|
await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS));
|
||||||
|
|
||||||
// Force kill with SIGKILL if still alive
|
// Force kill with SIGKILL if still alive
|
||||||
try {
|
try {
|
||||||
|
|||||||
+27
-10
@@ -18,7 +18,16 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkS
|
|||||||
import { writeFile, rename, unlink, copyFile, access } from 'node:fs/promises';
|
import { writeFile, rename, unlink, copyFile, access } from 'node:fs/promises';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { AppState, createInitialState, RalphSessionState, createInitialRalphSessionState, GlobalStats, createInitialGlobalStats, TokenStats, TokenUsageEntry } from './types.js';
|
import {
|
||||||
|
AppState,
|
||||||
|
createInitialState,
|
||||||
|
RalphSessionState,
|
||||||
|
createInitialRalphSessionState,
|
||||||
|
GlobalStats,
|
||||||
|
createInitialGlobalStats,
|
||||||
|
TokenStats,
|
||||||
|
TokenUsageEntry,
|
||||||
|
} from './types.js';
|
||||||
import { MAX_SESSION_TOKENS } from './utils/index.js';
|
import { MAX_SESSION_TOKENS } from './utils/index.js';
|
||||||
|
|
||||||
/** Debounce delay for batching state writes (ms) */
|
/** Debounce delay for batching state writes (ms) */
|
||||||
@@ -299,7 +308,11 @@ export class StateStore {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[StateStore] Failed to write state file:', err);
|
console.error('[StateStore] Failed to write state file:', err);
|
||||||
this.consecutiveSaveFailures++;
|
this.consecutiveSaveFailures++;
|
||||||
try { if (existsSync(tempPath)) unlinkSync(tempPath); } catch { /* ignore */ }
|
try {
|
||||||
|
if (existsSync(tempPath)) unlinkSync(tempPath);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||||
console.error('[StateStore] Circuit breaker OPEN - writes failing repeatedly');
|
console.error('[StateStore] Circuit breaker OPEN - writes failing repeatedly');
|
||||||
this.circuitBreakerOpen = true;
|
this.circuitBreakerOpen = true;
|
||||||
@@ -384,7 +397,10 @@ export class StateStore {
|
|||||||
* @param activeSessionIds - Set of currently active session IDs
|
* @param activeSessionIds - Set of currently active session IDs
|
||||||
* @returns Number of sessions cleaned up
|
* @returns Number of sessions cleaned up
|
||||||
*/
|
*/
|
||||||
cleanupStaleSessions(activeSessionIds: Set<string>): { count: number; cleaned: Array<{ id: string; name?: string }> } {
|
cleanupStaleSessions(activeSessionIds: Set<string>): {
|
||||||
|
count: number;
|
||||||
|
cleaned: Array<{ id: string; name?: string }>;
|
||||||
|
} {
|
||||||
const allSessionIds = Object.keys(this.state.sessions);
|
const allSessionIds = Object.keys(this.state.sessions);
|
||||||
const cleaned: Array<{ id: string; name?: string }> = [];
|
const cleaned: Array<{ id: string; name?: string }> = [];
|
||||||
|
|
||||||
@@ -481,7 +497,9 @@ export class StateStore {
|
|||||||
}
|
}
|
||||||
// Reject negative values
|
// Reject negative values
|
||||||
if (inputTokens < 0 || outputTokens < 0 || cost < 0) {
|
if (inputTokens < 0 || outputTokens < 0 || cost < 0) {
|
||||||
console.warn(`[StateStore] Rejected negative global stats: input=${inputTokens}, output=${outputTokens}, cost=${cost}`);
|
console.warn(
|
||||||
|
`[StateStore] Rejected negative global stats: input=${inputTokens}, output=${outputTokens}, cost=${cost}`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,7 +523,9 @@ export class StateStore {
|
|||||||
* Returns aggregate stats combining global (deleted sessions) + active sessions.
|
* Returns aggregate stats combining global (deleted sessions) + active sessions.
|
||||||
* @param activeSessions Map of active session states
|
* @param activeSessions Map of active session states
|
||||||
*/
|
*/
|
||||||
getAggregateStats(activeSessions: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }>): {
|
getAggregateStats(
|
||||||
|
activeSessions: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }>
|
||||||
|
): {
|
||||||
totalInputTokens: number;
|
totalInputTokens: number;
|
||||||
totalOutputTokens: number;
|
totalOutputTokens: number;
|
||||||
totalCost: number;
|
totalCost: number;
|
||||||
@@ -602,7 +622,7 @@ export class StateStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find or create today's entry
|
// Find or create today's entry
|
||||||
let todayEntry = stats.daily.find(e => e.date === today);
|
let todayEntry = stats.daily.find((e) => e.date === today);
|
||||||
if (!todayEntry) {
|
if (!todayEntry) {
|
||||||
todayEntry = {
|
todayEntry = {
|
||||||
date: today,
|
date: today,
|
||||||
@@ -617,10 +637,7 @@ export class StateStore {
|
|||||||
// Accumulate tokens
|
// Accumulate tokens
|
||||||
todayEntry.inputTokens += inputTokens;
|
todayEntry.inputTokens += inputTokens;
|
||||||
todayEntry.outputTokens += outputTokens;
|
todayEntry.outputTokens += outputTokens;
|
||||||
todayEntry.estimatedCost = this.calculateEstimatedCost(
|
todayEntry.estimatedCost = this.calculateEstimatedCost(todayEntry.inputTokens, todayEntry.outputTokens);
|
||||||
todayEntry.inputTokens,
|
|
||||||
todayEntry.outputTokens
|
|
||||||
);
|
|
||||||
|
|
||||||
// Only increment session count for unique sessions
|
// Only increment session count for unique sessions
|
||||||
if (sessionId && !this.dailySessionIds.has(sessionId)) {
|
if (sessionId && !this.dailySessionIds.has(sessionId)) {
|
||||||
|
|||||||
+96
-68
@@ -77,16 +77,18 @@ export interface SubagentTranscriptEntry {
|
|||||||
input_tokens?: number;
|
input_tokens?: number;
|
||||||
output_tokens?: number;
|
output_tokens?: number;
|
||||||
};
|
};
|
||||||
content: string | Array<{
|
content:
|
||||||
type: 'text' | 'tool_use' | 'tool_result';
|
| string
|
||||||
text?: string;
|
| Array<{
|
||||||
name?: string;
|
type: 'text' | 'tool_use' | 'tool_result';
|
||||||
id?: string; // tool_use_id for tool_use blocks
|
text?: string;
|
||||||
tool_use_id?: string; // tool_use_id for tool_result blocks
|
name?: string;
|
||||||
input?: Record<string, unknown>;
|
id?: string; // tool_use_id for tool_use blocks
|
||||||
content?: string | Array<{ type: string; text?: string }>; // tool_result content
|
tool_use_id?: string; // tool_use_id for tool_result blocks
|
||||||
is_error?: boolean; // For tool_result errors
|
input?: Record<string, unknown>;
|
||||||
}>;
|
content?: string | Array<{ type: string; text?: string }>; // tool_result content
|
||||||
|
is_error?: boolean; // For tool_result errors
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
data?: {
|
data?: {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -135,11 +137,11 @@ const MAX_TRACKED_AGENTS = 500; // Maximum agents to track (LRU eviction when ex
|
|||||||
|
|
||||||
// Internal Claude Code agent patterns to filter out (not real user-initiated subagents)
|
// Internal Claude Code agent patterns to filter out (not real user-initiated subagents)
|
||||||
const INTERNAL_AGENT_PATTERNS = [
|
const INTERNAL_AGENT_PATTERNS = [
|
||||||
/^\[?SUGGESTION MODE/i, // Claude Code's internal suggestion mode
|
/^\[?SUGGESTION MODE/i, // Claude Code's internal suggestion mode
|
||||||
/^Suggest what user might/i, // Suggestion mode prompt variant
|
/^Suggest what user might/i, // Suggestion mode prompt variant
|
||||||
/aprompt/i, // Internal prompt agent (anywhere in string)
|
/aprompt/i, // Internal prompt agent (anywhere in string)
|
||||||
/^a\s?prompt/i, // Variants of internal prompt agent
|
/^a\s?prompt/i, // Variants of internal prompt agent
|
||||||
/^prompt$/i, // Just "prompt"
|
/^prompt$/i, // Just "prompt"
|
||||||
];
|
];
|
||||||
|
|
||||||
// Minimum description length - very short descriptions are likely internal or malformed
|
// Minimum description length - very short descriptions are likely internal or malformed
|
||||||
@@ -194,7 +196,7 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
if (!description) return false;
|
if (!description) return false;
|
||||||
// Filter out very short descriptions (likely internal or malformed)
|
// Filter out very short descriptions (likely internal or malformed)
|
||||||
if (description.length < MIN_DESCRIPTION_LENGTH) return true;
|
if (description.length < MIN_DESCRIPTION_LENGTH) return true;
|
||||||
return INTERNAL_AGENT_PATTERNS.some(pattern => pattern.test(description));
|
return INTERNAL_AGENT_PATTERNS.some((pattern) => pattern.test(description));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -263,7 +265,7 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
if (await this.checkSubagentFileAlive(info)) continue;
|
if (await this.checkSubagentFileAlive(info)) continue;
|
||||||
|
|
||||||
// Tier 2: Cached PID check (~0.1ms per agent)
|
// Tier 2: Cached PID check (~0.1ms per agent)
|
||||||
if (info.pid && await this.checkPidAlive(info.pid)) continue;
|
if (info.pid && (await this.checkPidAlive(info.pid))) continue;
|
||||||
|
|
||||||
// Tiers 1+2 failed — need expensive scan for this agent
|
// Tiers 1+2 failed — need expensive scan for this agent
|
||||||
needsFullScan.push(info);
|
needsFullScan.push(info);
|
||||||
@@ -322,20 +324,35 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
resolve(stdout);
|
resolve(stdout);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const pids = pgrepOutput.trim().split('\n').filter(Boolean).map(s => parseInt(s, 10)).filter(n => !Number.isNaN(n));
|
const pids = pgrepOutput
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((s) => parseInt(s, 10))
|
||||||
|
.filter((n) => !Number.isNaN(n));
|
||||||
|
|
||||||
// Read /proc for all PIDs in parallel
|
// Read /proc for all PIDs in parallel
|
||||||
await Promise.all(pids.map(async (pid) => {
|
await Promise.all(
|
||||||
let environ = '';
|
pids.map(async (pid) => {
|
||||||
let cmdline = '';
|
let environ = '';
|
||||||
try { environ = await readFile(`/proc/${pid}/environ`, 'utf8'); } catch { /* skip */ }
|
let cmdline = '';
|
||||||
try { cmdline = await readFile(`/proc/${pid}/cmdline`, 'utf8'); } catch { /* skip */ }
|
try {
|
||||||
// Skip main Codeman-managed Claude processes — only track subagents
|
environ = await readFile(`/proc/${pid}/environ`, 'utf8');
|
||||||
if (environ.includes('CODEMAN_MUX=1')) return;
|
} catch {
|
||||||
if (environ || cmdline) {
|
/* skip */
|
||||||
result.set(pid, { environ, cmdline });
|
}
|
||||||
}
|
try {
|
||||||
}));
|
cmdline = await readFile(`/proc/${pid}/cmdline`, 'utf8');
|
||||||
|
} catch {
|
||||||
|
/* skip */
|
||||||
|
}
|
||||||
|
// Skip main Codeman-managed Claude processes — only track subagents
|
||||||
|
if (environ.includes('CODEMAN_MUX=1')) return;
|
||||||
|
if (environ || cmdline) {
|
||||||
|
result.set(pid, { environ, cmdline });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// Update cached PIDs on tracked agents
|
// Update cached PIDs on tracked agents
|
||||||
for (const [pid, procInfo] of result) {
|
for (const [pid, procInfo] of result) {
|
||||||
@@ -567,9 +584,7 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
getSubagentsForSession(workingDir: string): SubagentInfo[] {
|
getSubagentsForSession(workingDir: string): SubagentInfo[] {
|
||||||
const projectHash = this.getProjectHash(workingDir);
|
const projectHash = this.getProjectHash(workingDir);
|
||||||
return Array.from(this.agentInfo.values()).filter(
|
return Array.from(this.agentInfo.values()).filter((info) => info.projectHash === projectHash);
|
||||||
(info) => info.projectHash === projectHash
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -815,7 +830,8 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
} else if (content.type === 'text' && content.text) {
|
} else if (content.type === 'text' && content.text) {
|
||||||
const text = content.text.trim();
|
const text = content.text.trim();
|
||||||
if (text.length > 0) {
|
if (text.length > 0) {
|
||||||
const preview = text.length > TEXT_PREVIEW_LENGTH ? text.substring(0, TEXT_PREVIEW_LENGTH) + '...' : text;
|
const preview =
|
||||||
|
text.length > TEXT_PREVIEW_LENGTH ? text.substring(0, TEXT_PREVIEW_LENGTH) + '...' : text;
|
||||||
lines.push(`${this.formatTime(entry.timestamp)} 💬 ${preview.replace(/\n/g, ' ')}`);
|
lines.push(`${this.formatTime(entry.timestamp)} 💬 ${preview.replace(/\n/g, ' ')}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -860,7 +876,7 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
const MAX_LEN = SMART_TITLE_MAX_LENGTH;
|
const MAX_LEN = SMART_TITLE_MAX_LENGTH;
|
||||||
|
|
||||||
// Get first line/sentence
|
// Get first line/sentence
|
||||||
let title = text.split('\n')[0].trim();
|
const title = text.split('\n')[0].trim();
|
||||||
|
|
||||||
// If already short enough, use it
|
// If already short enough, use it
|
||||||
if (title.length <= MAX_LEN) {
|
if (title.length <= MAX_LEN) {
|
||||||
@@ -932,14 +948,18 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
|
|
||||||
// Check cache first (covers burst of simultaneous agent discoveries)
|
// Check cache first (covers burst of simultaneous agent discoveries)
|
||||||
const cached = this.parentDescriptionCache.get(cacheKey);
|
const cached = this.parentDescriptionCache.get(cacheKey);
|
||||||
if (cached && (Date.now() - cached.timestamp) < CACHE_TTL_MS) {
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||||
return cached.descriptions.get(agentId);
|
return cached.descriptions.get(agentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// The parent session's transcript is at: ~/.claude/projects/{projectHash}/{sessionId}.jsonl
|
// The parent session's transcript is at: ~/.claude/projects/{projectHash}/{sessionId}.jsonl
|
||||||
const transcriptPath = join(CLAUDE_PROJECTS_DIR, projectHash, `${sessionId}.jsonl`);
|
const transcriptPath = join(CLAUDE_PROJECTS_DIR, projectHash, `${sessionId}.jsonl`);
|
||||||
try { await statAsync(transcriptPath); } catch { return undefined; }
|
try {
|
||||||
|
await statAsync(transcriptPath);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const content = await readFile(transcriptPath, 'utf8');
|
const content = await readFile(transcriptPath, 'utf8');
|
||||||
const lines = content.split('\n').filter((l) => l.trim());
|
const lines = content.split('\n').filter((l) => l.trim());
|
||||||
@@ -949,11 +969,7 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
try {
|
try {
|
||||||
const entry = JSON.parse(line);
|
const entry = JSON.parse(line);
|
||||||
if (
|
if (entry.type === 'user' && entry.toolUseResult?.agentId && entry.toolUseResult?.description) {
|
||||||
entry.type === 'user' &&
|
|
||||||
entry.toolUseResult?.agentId &&
|
|
||||||
entry.toolUseResult?.description
|
|
||||||
) {
|
|
||||||
descriptions.set(entry.toolUseResult.agentId, entry.toolUseResult.description);
|
descriptions.set(entry.toolUseResult.agentId, entry.toolUseResult.description);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -982,7 +998,11 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
let lineCount = 0;
|
let lineCount = 0;
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
rl.on('line', (line) => {
|
rl.on('line', (line) => {
|
||||||
if (resolved || lineCount >= 5) { rl.close(); stream.destroy(); return; }
|
if (resolved || lineCount >= 5) {
|
||||||
|
rl.close();
|
||||||
|
stream.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
lineCount++;
|
lineCount++;
|
||||||
if (!line.trim()) return;
|
if (!line.trim()) return;
|
||||||
try {
|
try {
|
||||||
@@ -1008,8 +1028,12 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
// Skip malformed lines
|
// Skip malformed lines
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
rl.on('close', () => { if (!resolved) resolve(undefined); });
|
rl.on('close', () => {
|
||||||
rl.on('error', () => { if (!resolved) resolve(undefined); });
|
if (!resolved) resolve(undefined);
|
||||||
|
});
|
||||||
|
rl.on('error', () => {
|
||||||
|
if (!resolved) resolve(undefined);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Failed to read file
|
// Failed to read file
|
||||||
@@ -1021,7 +1045,11 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
* Scan for all subagent directories (async to avoid blocking event loop)
|
* Scan for all subagent directories (async to avoid blocking event loop)
|
||||||
*/
|
*/
|
||||||
private async scanForSubagents(): Promise<void> {
|
private async scanForSubagents(): Promise<void> {
|
||||||
try { await statAsync(CLAUDE_PROJECTS_DIR); } catch { return; }
|
try {
|
||||||
|
await statAsync(CLAUDE_PROJECTS_DIR);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const projects = await readdir(CLAUDE_PROJECTS_DIR);
|
const projects = await readdir(CLAUDE_PROJECTS_DIR);
|
||||||
@@ -1123,7 +1151,12 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
* @param sessionId Claude session ID
|
* @param sessionId Claude session ID
|
||||||
* @param isInitialScan If true, skip files older than STARTUP_MAX_FILE_AGE_MS
|
* @param isInitialScan If true, skip files older than STARTUP_MAX_FILE_AGE_MS
|
||||||
*/
|
*/
|
||||||
private async watchAgentFile(filePath: string, projectHash: string, sessionId: string, isInitialScan: boolean = false): Promise<void> {
|
private async watchAgentFile(
|
||||||
|
filePath: string,
|
||||||
|
projectHash: string,
|
||||||
|
sessionId: string,
|
||||||
|
isInitialScan: boolean = false
|
||||||
|
): Promise<void> {
|
||||||
if (this.fileWatchers.has(filePath)) return;
|
if (this.fileWatchers.has(filePath)) return;
|
||||||
|
|
||||||
const agentId = basename(filePath).replace('agent-', '').replace('.jsonl', '');
|
const agentId = basename(filePath).replace('agent-', '').replace('.jsonl', '');
|
||||||
@@ -1192,12 +1225,14 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
this.emit('subagent:discovered', info);
|
this.emit('subagent:discovered', info);
|
||||||
|
|
||||||
// Read existing content
|
// Read existing content
|
||||||
this.tailFile(filePath, agentId, sessionId, 0).then((position) => {
|
this.tailFile(filePath, agentId, sessionId, 0)
|
||||||
this.filePositions.set(filePath, position);
|
.then((position) => {
|
||||||
}).catch((err) => {
|
this.filePositions.set(filePath, position);
|
||||||
// Log but don't throw - non-critical background operation
|
})
|
||||||
console.warn(`[SubagentWatcher] Failed to read initial content for ${agentId}:`, err);
|
.catch((err) => {
|
||||||
});
|
// Log but don't throw - non-critical background operation
|
||||||
|
console.warn(`[SubagentWatcher] Failed to read initial content for ${agentId}:`, err);
|
||||||
|
});
|
||||||
|
|
||||||
// Watch for changes
|
// Watch for changes
|
||||||
try {
|
try {
|
||||||
@@ -1269,12 +1304,7 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Tail a file from a specific position
|
* Tail a file from a specific position
|
||||||
*/
|
*/
|
||||||
private async tailFile(
|
private async tailFile(filePath: string, agentId: string, sessionId: string, fromPosition: number): Promise<number> {
|
||||||
filePath: string,
|
|
||||||
agentId: string,
|
|
||||||
sessionId: string,
|
|
||||||
fromPosition: number
|
|
||||||
): Promise<number> {
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let position = fromPosition;
|
let position = fromPosition;
|
||||||
|
|
||||||
@@ -1337,11 +1367,7 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
// Check if this is first user message and description is missing
|
// Check if this is first user message and description is missing
|
||||||
if (info && !info.description && entry.type === 'user' && entry.message?.content) {
|
if (info && !info.description && entry.type === 'user' && entry.message?.content) {
|
||||||
// First try parent transcript (most reliable)
|
// First try parent transcript (most reliable)
|
||||||
let description = await this.extractDescriptionFromParentTranscript(
|
let description = await this.extractDescriptionFromParentTranscript(info.projectHash, info.sessionId, agentId);
|
||||||
info.projectHash,
|
|
||||||
info.sessionId,
|
|
||||||
agentId
|
|
||||||
);
|
|
||||||
// Fallback: extract smart title from the prompt content
|
// Fallback: extract smart title from the prompt content
|
||||||
if (!description) {
|
if (!description) {
|
||||||
let text: string | undefined;
|
let text: string | undefined;
|
||||||
@@ -1378,9 +1404,11 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
resultCount: entry.data.resultCount,
|
resultCount: entry.data.resultCount,
|
||||||
// Extract hook event info if present
|
// Extract hook event info if present
|
||||||
hookEvent: entry.data.hookEvent,
|
hookEvent: entry.data.hookEvent,
|
||||||
hookName: entry.data.hookName || (entry.data.hookEvent && entry.data.tool_name
|
hookName:
|
||||||
? `${entry.data.hookEvent}:${entry.data.tool_name}`
|
entry.data.hookName ||
|
||||||
: undefined),
|
(entry.data.hookEvent && entry.data.tool_name
|
||||||
|
? `${entry.data.hookEvent}:${entry.data.tool_name}`
|
||||||
|
: undefined),
|
||||||
};
|
};
|
||||||
this.emit('subagent:progress', progress);
|
this.emit('subagent:progress', progress);
|
||||||
} else if (entry.type === 'assistant' && entry.message?.content) {
|
} else if (entry.type === 'assistant' && entry.message?.content) {
|
||||||
@@ -1531,8 +1559,8 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
if (typeof content === 'string') return content;
|
if (typeof content === 'string') return content;
|
||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
return content
|
return content
|
||||||
.filter(c => c.type === 'text' && c.text)
|
.filter((c) => c.type === 'text' && c.text)
|
||||||
.map(c => c.text)
|
.map((c) => c.text)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
|
|||||||
+15
-7
@@ -196,7 +196,9 @@ export class TaskQueue extends EventEmitter {
|
|||||||
private validateDependencies(taskId: string, dependencies: string[]): void {
|
private validateDependencies(taskId: string, dependencies: string[]): void {
|
||||||
for (const depId of dependencies) {
|
for (const depId of dependencies) {
|
||||||
if (this.wouldCreateCycle(taskId, depId)) {
|
if (this.wouldCreateCycle(taskId, depId)) {
|
||||||
throw new Error(`Circular dependency detected: adding dependency ${depId} to task ${taskId} would create a cycle`);
|
throw new Error(
|
||||||
|
`Circular dependency detected: adding dependency ${depId} to task ${taskId} would create a cycle`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,14 +210,21 @@ export class TaskQueue extends EventEmitter {
|
|||||||
|
|
||||||
/** Gets the currently running task for a session, if any. */
|
/** Gets the currently running task for a session, if any. */
|
||||||
getRunningTaskForSession(sessionId: string): Task | null {
|
getRunningTaskForSession(sessionId: string): Task | null {
|
||||||
return this.getAllTasks().find(
|
return this.getAllTasks().find((t) => t.isRunning() && t.assignedSessionId === sessionId) || null;
|
||||||
(t) => t.isRunning() && t.assignedSessionId === sessionId
|
|
||||||
) || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gets counts of tasks by status (single-pass). */
|
/** Gets counts of tasks by status (single-pass). */
|
||||||
getCount(): { total: number; pending: number; running: number; completed: number; failed: number } {
|
getCount(): {
|
||||||
let pending = 0, running = 0, completed = 0, failed = 0;
|
total: number;
|
||||||
|
pending: number;
|
||||||
|
running: number;
|
||||||
|
completed: number;
|
||||||
|
failed: number;
|
||||||
|
} {
|
||||||
|
let pending = 0,
|
||||||
|
running = 0,
|
||||||
|
completed = 0,
|
||||||
|
failed = 0;
|
||||||
for (const task of this.tasks.values()) {
|
for (const task of this.tasks.values()) {
|
||||||
if (task.isPending()) pending++;
|
if (task.isPending()) pending++;
|
||||||
else if (task.isRunning()) running++;
|
else if (task.isRunning()) running++;
|
||||||
@@ -258,7 +267,6 @@ export class TaskQueue extends EventEmitter {
|
|||||||
this.tasks.clear();
|
this.tasks.clear();
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance
|
// Singleton instance
|
||||||
|
|||||||
+25
-19
@@ -51,21 +51,13 @@ const MAX_PENDING_TOOL_USES = 100;
|
|||||||
* Used as fallback when JSON parsing doesn't capture the launch.
|
* Used as fallback when JSON parsing doesn't capture the launch.
|
||||||
* Capture group 1: Agent/task type name
|
* Capture group 1: Agent/task type name
|
||||||
*/
|
*/
|
||||||
const LAUNCH_PATTERNS = [
|
const LAUNCH_PATTERNS = [/Launching\s+(\w+)\s+agent/i, /Starting\s+(\w+)\s+task/i, /Spawning\s+(\w+)\s+agent/i];
|
||||||
/Launching\s+(\w+)\s+agent/i,
|
|
||||||
/Starting\s+(\w+)\s+task/i,
|
|
||||||
/Spawning\s+(\w+)\s+agent/i,
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Patterns that indicate a task has completed.
|
* Patterns that indicate a task has completed.
|
||||||
* Used as fallback when JSON parsing doesn't capture the result.
|
* Used as fallback when JSON parsing doesn't capture the result.
|
||||||
*/
|
*/
|
||||||
const COMPLETE_PATTERNS = [
|
const COMPLETE_PATTERNS = [/Task\s+completed/i, /Agent\s+finished/i, /Background\s+task\s+done/i];
|
||||||
/Task\s+completed/i,
|
|
||||||
/Agent\s+finished/i,
|
|
||||||
/Background\s+task\s+done/i,
|
|
||||||
];
|
|
||||||
|
|
||||||
// ========== Type Definitions ==========
|
// ========== Type Definitions ==========
|
||||||
|
|
||||||
@@ -218,7 +210,10 @@ export class TaskTracker extends EventEmitter {
|
|||||||
private taskStack: string[] = [];
|
private taskStack: string[] = [];
|
||||||
|
|
||||||
/** Pending tool_use blocks waiting for results (with timestamp for cleanup) */
|
/** Pending tool_use blocks waiting for results (with timestamp for cleanup) */
|
||||||
private pendingToolUses: Map<string, { description: string; subagentType: string; parentId: string | null; createdAt: number }> = new Map();
|
private pendingToolUses: Map<
|
||||||
|
string,
|
||||||
|
{ description: string; subagentType: string; parentId: string | null; createdAt: number }
|
||||||
|
> = new Map();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new TaskTracker instance.
|
* Creates a new TaskTracker instance.
|
||||||
@@ -316,7 +311,12 @@ export class TaskTracker extends EventEmitter {
|
|||||||
const parentId = this.taskStack.length > 0 ? this.taskStack[this.taskStack.length - 1] : null;
|
const parentId = this.taskStack.length > 0 ? this.taskStack[this.taskStack.length - 1] : null;
|
||||||
|
|
||||||
// Store pending tool use - task starts when we see activity
|
// Store pending tool use - task starts when we see activity
|
||||||
this.pendingToolUses.set(toolUseId, { description, subagentType, parentId, createdAt: Date.now() });
|
this.pendingToolUses.set(toolUseId, {
|
||||||
|
description,
|
||||||
|
subagentType,
|
||||||
|
parentId,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
// Clean up old pending entries to prevent unbounded growth
|
// Clean up old pending entries to prevent unbounded growth
|
||||||
this.cleanupOldPendingToolUses();
|
this.cleanupOldPendingToolUses();
|
||||||
@@ -361,9 +361,7 @@ export class TaskTracker extends EventEmitter {
|
|||||||
if (task) {
|
if (task) {
|
||||||
task.status = block.is_error ? 'failed' : 'completed';
|
task.status = block.is_error ? 'failed' : 'completed';
|
||||||
task.endTime = Date.now();
|
task.endTime = Date.now();
|
||||||
task.output = typeof block.content === 'string'
|
task.output = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
|
||||||
? block.content
|
|
||||||
: JSON.stringify(block.content);
|
|
||||||
|
|
||||||
// Remove from stack
|
// Remove from stack
|
||||||
const stackIndex = this.taskStack.indexOf(toolUseId);
|
const stackIndex = this.taskStack.indexOf(toolUseId);
|
||||||
@@ -610,13 +608,21 @@ export class TaskTracker extends EventEmitter {
|
|||||||
* - failed: Tasks that ended with errors
|
* - failed: Tasks that ended with errors
|
||||||
*/
|
*/
|
||||||
getStats(): { total: number; running: number; completed: number; failed: number } {
|
getStats(): { total: number; running: number; completed: number; failed: number } {
|
||||||
let running = 0, completed = 0, failed = 0;
|
let running = 0,
|
||||||
|
completed = 0,
|
||||||
|
failed = 0;
|
||||||
|
|
||||||
for (const task of this.tasks.values()) {
|
for (const task of this.tasks.values()) {
|
||||||
switch (task.status) {
|
switch (task.status) {
|
||||||
case 'running': running++; break;
|
case 'running':
|
||||||
case 'completed': completed++; break;
|
running++;
|
||||||
case 'failed': failed++; break;
|
break;
|
||||||
|
case 'completed':
|
||||||
|
completed++;
|
||||||
|
break;
|
||||||
|
case 'failed':
|
||||||
|
failed++;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
assertNever(task.status, `Unhandled BackgroundTask status: ${task.status}`);
|
assertNever(task.status, `Unhandled BackgroundTask status: ${task.status}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-16
@@ -86,13 +86,13 @@ export class TeamWatcher extends EventEmitter {
|
|||||||
getTeamTasks(teamName: string): TeamTask[] {
|
getTeamTasks(teamName: string): TeamTask[] {
|
||||||
const tasks = this.teamTasks.get(teamName);
|
const tasks = this.teamTasks.get(teamName);
|
||||||
if (!tasks) return [];
|
if (!tasks) return [];
|
||||||
return tasks.filter(t => !t.metadata?._internal);
|
return tasks.filter((t) => !t.metadata?._internal);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Count active (non-completed) tasks for a team */
|
/** Count active (non-completed) tasks for a team */
|
||||||
getActiveTaskCount(teamName: string): number {
|
getActiveTaskCount(teamName: string): number {
|
||||||
const tasks = this.getTeamTasks(teamName);
|
const tasks = this.getTeamTasks(teamName);
|
||||||
return tasks.filter(t => t.status !== 'completed').length;
|
return tasks.filter((t) => t.status !== 'completed').length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get inbox messages for a team member (or all members) */
|
/** Get inbox messages for a team member (or all members) */
|
||||||
@@ -108,9 +108,7 @@ export class TeamWatcher extends EventEmitter {
|
|||||||
messages.push(...msgs);
|
messages.push(...msgs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return messages.sort((a, b) =>
|
return messages.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
||||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if a session has active teammates (for idle detection) */
|
/** Check if a session has active teammates (for idle detection) */
|
||||||
@@ -119,7 +117,7 @@ export class TeamWatcher extends EventEmitter {
|
|||||||
if (!team) return false;
|
if (!team) return false;
|
||||||
|
|
||||||
// Check if any non-lead members exist (they are active by definition while present)
|
// Check if any non-lead members exist (they are active by definition while present)
|
||||||
const teammates = team.members.filter(m => m.agentType !== 'team-lead');
|
const teammates = team.members.filter((m) => m.agentType !== 'team-lead');
|
||||||
if (teammates.length === 0) return false;
|
if (teammates.length === 0) return false;
|
||||||
|
|
||||||
// Check if team has active (non-completed) tasks
|
// Check if team has active (non-completed) tasks
|
||||||
@@ -132,19 +130,17 @@ export class TeamWatcher extends EventEmitter {
|
|||||||
const team = this.teams.get(teamName);
|
const team = this.teams.get(teamName);
|
||||||
if (!team) return [];
|
if (!team) return [];
|
||||||
|
|
||||||
return team.members
|
return team.members.filter(
|
||||||
.filter((m): m is TeamMember & { tmuxPaneId: string } =>
|
(m): m is TeamMember & { tmuxPaneId: string } =>
|
||||||
m.agentType !== 'team-lead' &&
|
m.agentType !== 'team-lead' && !!m.tmuxPaneId && m.tmuxPaneId !== 'in-process'
|
||||||
!!m.tmuxPaneId &&
|
);
|
||||||
m.tmuxPaneId !== 'in-process'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get count of active teammates for a session */
|
/** Get count of active teammates for a session */
|
||||||
getActiveTeammateCount(sessionId: string): number {
|
getActiveTeammateCount(sessionId: string): number {
|
||||||
const team = this.getTeamForSession(sessionId);
|
const team = this.getTeamForSession(sessionId);
|
||||||
if (!team) return 0;
|
if (!team) return 0;
|
||||||
return team.members.filter(m => m.agentType !== 'team-lead').length;
|
return team.members.filter((m) => m.agentType !== 'team-lead').length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Private Methods ==========
|
// ========== Private Methods ==========
|
||||||
@@ -257,7 +253,7 @@ export class TeamWatcher extends EventEmitter {
|
|||||||
|
|
||||||
let taskFiles: string[];
|
let taskFiles: string[];
|
||||||
try {
|
try {
|
||||||
taskFiles = (await readdir(teamTaskDir)).filter(f => f.endsWith('.json') && f !== '.lock');
|
taskFiles = (await readdir(teamTaskDir)).filter((f) => f.endsWith('.json') && f !== '.lock');
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -285,7 +281,7 @@ export class TeamWatcher extends EventEmitter {
|
|||||||
|
|
||||||
let inboxFiles: string[];
|
let inboxFiles: string[];
|
||||||
try {
|
try {
|
||||||
inboxFiles = (await readdir(inboxDir)).filter(f => f.endsWith('.json'));
|
inboxFiles = (await readdir(inboxDir)).filter((f) => f.endsWith('.json'));
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -314,7 +310,7 @@ export class TeamWatcher extends EventEmitter {
|
|||||||
this.inboxCache.set(cacheKey, messages);
|
this.inboxCache.set(cacheKey, messages);
|
||||||
|
|
||||||
// Emit new messages — compare by timestamp to handle deletions/reordering
|
// Emit new messages — compare by timestamp to handle deletions/reordering
|
||||||
const prevTimestamps = new Set(previous?.map(m => m.timestamp) || []);
|
const prevTimestamps = new Set(previous?.map((m) => m.timestamp) || []);
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
if (!prevTimestamps.has(msg.timestamp)) {
|
if (!prevTimestamps.has(msg.timestamp)) {
|
||||||
this.emit('inboxMessage', { teamName, member: memberName, message: msg });
|
this.emit('inboxMessage', { teamName, member: memberName, message: msg });
|
||||||
|
|||||||
+215
-151
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-31
@@ -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(', ')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-11
@@ -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,
|
||||||
|
|||||||
+184
-112
@@ -15,11 +15,23 @@ import { SAFE_PATH_PATTERN } from '../utils/regex-patterns.js';
|
|||||||
/** Validate a path string: no shell metacharacters, no traversal, must be absolute */
|
/** Validate a path string: no shell metacharacters, no traversal, must be absolute */
|
||||||
export function isValidWorkingDir(p: string): boolean {
|
export function isValidWorkingDir(p: string): boolean {
|
||||||
if (!p || !p.startsWith('/')) return false;
|
if (!p || !p.startsWith('/')) return false;
|
||||||
if (p.includes(';') || p.includes('&') || p.includes('|') ||
|
if (
|
||||||
p.includes('$') || p.includes('`') || p.includes('(') ||
|
p.includes(';') ||
|
||||||
p.includes(')') || p.includes('{') || p.includes('}') ||
|
p.includes('&') ||
|
||||||
p.includes('<') || p.includes('>') || p.includes("'") ||
|
p.includes('|') ||
|
||||||
p.includes('"') || p.includes('\n') || p.includes('\r')) {
|
p.includes('$') ||
|
||||||
|
p.includes('`') ||
|
||||||
|
p.includes('(') ||
|
||||||
|
p.includes(')') ||
|
||||||
|
p.includes('{') ||
|
||||||
|
p.includes('}') ||
|
||||||
|
p.includes('<') ||
|
||||||
|
p.includes('>') ||
|
||||||
|
p.includes("'") ||
|
||||||
|
p.includes('"') ||
|
||||||
|
p.includes('\n') ||
|
||||||
|
p.includes('\r')
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (p.includes('..')) return false;
|
if (p.includes('..')) return false;
|
||||||
@@ -38,25 +50,35 @@ const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_'];
|
|||||||
|
|
||||||
/** Env var keys that are always blocked (security-sensitive) */
|
/** Env var keys that are always blocked (security-sensitive) */
|
||||||
const BLOCKED_ENV_KEYS = new Set([
|
const BLOCKED_ENV_KEYS = new Set([
|
||||||
'PATH', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'NODE_OPTIONS',
|
'PATH',
|
||||||
'CODEMAN_MUX_NAME', 'CODEMAN_TMUX',
|
'LD_PRELOAD',
|
||||||
'OPENCODE_SERVER_PASSWORD', // Security-sensitive: server auth password
|
'LD_LIBRARY_PATH',
|
||||||
|
'NODE_OPTIONS',
|
||||||
|
'CODEMAN_MUX_NAME',
|
||||||
|
'CODEMAN_TMUX',
|
||||||
|
'OPENCODE_SERVER_PASSWORD', // Security-sensitive: server auth password
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Validate that an env var key is allowed */
|
/** Validate that an env var key is allowed */
|
||||||
function isAllowedEnvKey(key: string): boolean {
|
function isAllowedEnvKey(key: string): boolean {
|
||||||
if (BLOCKED_ENV_KEYS.has(key)) return false;
|
if (BLOCKED_ENV_KEYS.has(key)) return false;
|
||||||
return ALLOWED_ENV_PREFIXES.some(prefix => key.startsWith(prefix));
|
return ALLOWED_ENV_PREFIXES.some((prefix) => key.startsWith(prefix));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Zod schema for env overrides with allowlist enforcement */
|
/** Zod schema for env overrides with allowlist enforcement */
|
||||||
const safeEnvOverridesSchema = z.record(z.string(), z.string()).optional().refine(
|
const safeEnvOverridesSchema = z
|
||||||
(val) => {
|
.record(z.string(), z.string())
|
||||||
if (!val) return true;
|
.optional()
|
||||||
return Object.keys(val).every(isAllowedEnvKey);
|
.refine(
|
||||||
},
|
(val) => {
|
||||||
{ message: 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_* and OPENCODE_* keys are allowed.' },
|
if (!val) return true;
|
||||||
);
|
return Object.keys(val).every(isAllowedEnvKey);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_* and OPENCODE_* keys are allowed.',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// ========== Session Routes ==========
|
// ========== Session Routes ==========
|
||||||
|
|
||||||
@@ -65,16 +87,37 @@ const safeEnvOverridesSchema = z.record(z.string(), z.string()).optional().refin
|
|||||||
* Creates a new session with optional working directory, mode, and name.
|
* Creates a new session with optional working directory, mode, and name.
|
||||||
*/
|
*/
|
||||||
/** Schema for OpenCode-specific configuration */
|
/** Schema for OpenCode-specific configuration */
|
||||||
const OpenCodeConfigSchema = z.object({
|
const OpenCodeConfigSchema = z
|
||||||
model: z.string().max(100).regex(/^[a-zA-Z0-9._\-/]+$/).optional(),
|
.object({
|
||||||
autoAllowTools: z.boolean().optional(),
|
model: z
|
||||||
continueSession: z.string().max(100).regex(/^[a-zA-Z0-9_-]+$/).optional(),
|
.string()
|
||||||
forkSession: z.boolean().optional(),
|
.max(100)
|
||||||
configContent: z.string().max(10000).refine(
|
.regex(/^[a-zA-Z0-9._\-/]+$/)
|
||||||
(val) => { try { JSON.parse(val); return true; } catch { return false; } },
|
.optional(),
|
||||||
{ message: 'configContent must be valid JSON' },
|
autoAllowTools: z.boolean().optional(),
|
||||||
).optional(),
|
continueSession: z
|
||||||
}).optional();
|
.string()
|
||||||
|
.max(100)
|
||||||
|
.regex(/^[a-zA-Z0-9_-]+$/)
|
||||||
|
.optional(),
|
||||||
|
forkSession: z.boolean().optional(),
|
||||||
|
configContent: z
|
||||||
|
.string()
|
||||||
|
.max(10000)
|
||||||
|
.refine(
|
||||||
|
(val) => {
|
||||||
|
try {
|
||||||
|
JSON.parse(val);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ message: 'configContent must be valid JSON' }
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
export const CreateSessionSchema = z.object({
|
export const CreateSessionSchema = z.object({
|
||||||
workingDir: safePathSchema.optional(),
|
workingDir: safePathSchema.optional(),
|
||||||
@@ -108,7 +151,9 @@ export const ResizeSchema = z.object({
|
|||||||
* Creates a new case folder.
|
* Creates a new case folder.
|
||||||
*/
|
*/
|
||||||
export const CreateCaseSchema = z.object({
|
export const CreateCaseSchema = z.object({
|
||||||
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.'),
|
name: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.'),
|
||||||
description: z.string().max(1000).optional(),
|
description: z.string().max(1000).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,7 +164,10 @@ export const CreateCaseSchema = z.object({
|
|||||||
* Creates case (if needed) and starts interactive session.
|
* Creates case (if needed) and starts interactive session.
|
||||||
*/
|
*/
|
||||||
export const QuickStartSchema = z.object({
|
export const QuickStartSchema = z.object({
|
||||||
caseName: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.').optional(),
|
caseName: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.')
|
||||||
|
.optional(),
|
||||||
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
|
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
|
||||||
openCodeConfig: OpenCodeConfigSchema,
|
openCodeConfig: OpenCodeConfigSchema,
|
||||||
});
|
});
|
||||||
@@ -175,86 +223,100 @@ export const RespawnConfigSchema = z.object({
|
|||||||
* Schema for PUT /api/config
|
* Schema for PUT /api/config
|
||||||
* Updates application configuration with whitelist of allowed fields.
|
* Updates application configuration with whitelist of allowed fields.
|
||||||
*/
|
*/
|
||||||
export const ConfigUpdateSchema = z.object({
|
export const ConfigUpdateSchema = z
|
||||||
pollIntervalMs: z.number().int().min(100).max(60000).optional(),
|
.object({
|
||||||
defaultTimeoutMs: z.number().int().min(1000).max(3600000).optional(),
|
pollIntervalMs: z.number().int().min(100).max(60000).optional(),
|
||||||
maxConcurrentSessions: z.number().int().min(1).max(50).optional(),
|
defaultTimeoutMs: z.number().int().min(1000).max(3600000).optional(),
|
||||||
respawn: RespawnConfigSchema.optional(),
|
maxConcurrentSessions: z.number().int().min(1).max(50).optional(),
|
||||||
}).strict();
|
respawn: RespawnConfigSchema.optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema for PUT /api/settings
|
* Schema for PUT /api/settings
|
||||||
* Explicit allowlist of known settings fields — prevents arbitrary key persistence.
|
* Explicit allowlist of known settings fields — prevents arbitrary key persistence.
|
||||||
*/
|
*/
|
||||||
const NotificationEventSchema = z.object({
|
const NotificationEventSchema = z
|
||||||
enabled: z.boolean().optional(),
|
.object({
|
||||||
browser: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
audio: z.boolean().optional(),
|
browser: z.boolean().optional(),
|
||||||
push: z.boolean().optional(),
|
audio: z.boolean().optional(),
|
||||||
}).optional();
|
push: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
export const SettingsUpdateSchema = z.object({
|
export const SettingsUpdateSchema = z
|
||||||
// Paths
|
.object({
|
||||||
defaultClaudeMdPath: z.string().max(500).optional(),
|
// Paths
|
||||||
defaultWorkingDir: z.string().max(500).optional(),
|
defaultClaudeMdPath: z.string().max(500).optional(),
|
||||||
lastUsedCase: z.string().max(200).optional(),
|
defaultWorkingDir: z.string().max(500).optional(),
|
||||||
// Feature toggles
|
lastUsedCase: z.string().max(200).optional(),
|
||||||
ralphTrackerEnabled: z.boolean().optional(),
|
// Feature toggles
|
||||||
subagentTrackingEnabled: z.boolean().optional(),
|
ralphTrackerEnabled: z.boolean().optional(),
|
||||||
subagentActiveTabOnly: z.boolean().optional(),
|
subagentTrackingEnabled: z.boolean().optional(),
|
||||||
imageWatcherEnabled: z.boolean().optional(),
|
subagentActiveTabOnly: z.boolean().optional(),
|
||||||
tunnelEnabled: z.boolean().optional(),
|
imageWatcherEnabled: z.boolean().optional(),
|
||||||
tabTwoRows: z.boolean().optional(),
|
tunnelEnabled: z.boolean().optional(),
|
||||||
agentTeamsEnabled: z.boolean().optional(),
|
tabTwoRows: z.boolean().optional(),
|
||||||
// UI visibility
|
agentTeamsEnabled: z.boolean().optional(),
|
||||||
showFontControls: z.boolean().optional(),
|
// UI visibility
|
||||||
showSystemStats: z.boolean().optional(),
|
showFontControls: z.boolean().optional(),
|
||||||
showTokenCount: z.boolean().optional(),
|
showSystemStats: z.boolean().optional(),
|
||||||
showCost: z.boolean().optional(),
|
showTokenCount: z.boolean().optional(),
|
||||||
showLifecycleLog: z.boolean().optional(),
|
showCost: z.boolean().optional(),
|
||||||
showMonitor: z.boolean().optional(),
|
showLifecycleLog: z.boolean().optional(),
|
||||||
showProjectInsights: z.boolean().optional(),
|
showMonitor: z.boolean().optional(),
|
||||||
showFileBrowser: z.boolean().optional(),
|
showProjectInsights: z.boolean().optional(),
|
||||||
showSubagents: z.boolean().optional(),
|
showFileBrowser: z.boolean().optional(),
|
||||||
// Claude CLI settings
|
showSubagents: z.boolean().optional(),
|
||||||
claudeMode: z.string().max(50).optional(),
|
// Claude CLI settings
|
||||||
allowedTools: z.string().max(2000).optional(),
|
claudeMode: z.string().max(50).optional(),
|
||||||
// CPU priority
|
allowedTools: z.string().max(2000).optional(),
|
||||||
nice: z.object({
|
// CPU priority
|
||||||
enabled: z.boolean().optional(),
|
nice: z
|
||||||
niceValue: z.number().int().min(-20).max(19).optional(),
|
.object({
|
||||||
}).optional(),
|
enabled: z.boolean().optional(),
|
||||||
// Notification preferences (cross-device sync)
|
niceValue: z.number().int().min(-20).max(19).optional(),
|
||||||
notificationPreferences: z.object({
|
})
|
||||||
enabled: z.boolean().optional(),
|
.optional(),
|
||||||
browserNotifications: z.boolean().optional(),
|
// Notification preferences (cross-device sync)
|
||||||
audioAlerts: z.boolean().optional(),
|
notificationPreferences: z
|
||||||
stuckThresholdMs: z.number().optional(),
|
.object({
|
||||||
muteCritical: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
muteWarning: z.boolean().optional(),
|
browserNotifications: z.boolean().optional(),
|
||||||
muteInfo: z.boolean().optional(),
|
audioAlerts: z.boolean().optional(),
|
||||||
eventTypes: z.object({
|
stuckThresholdMs: z.number().optional(),
|
||||||
permission_prompt: NotificationEventSchema,
|
muteCritical: z.boolean().optional(),
|
||||||
elicitation_dialog: NotificationEventSchema,
|
muteWarning: z.boolean().optional(),
|
||||||
idle_prompt: NotificationEventSchema,
|
muteInfo: z.boolean().optional(),
|
||||||
stop: NotificationEventSchema,
|
eventTypes: z
|
||||||
session_error: NotificationEventSchema,
|
.object({
|
||||||
respawn_cycle: NotificationEventSchema,
|
permission_prompt: NotificationEventSchema,
|
||||||
token_milestone: NotificationEventSchema,
|
elicitation_dialog: NotificationEventSchema,
|
||||||
ralph_complete: NotificationEventSchema,
|
idle_prompt: NotificationEventSchema,
|
||||||
subagent_spawn: NotificationEventSchema,
|
stop: NotificationEventSchema,
|
||||||
subagent_complete: NotificationEventSchema,
|
session_error: NotificationEventSchema,
|
||||||
}).optional(),
|
respawn_cycle: NotificationEventSchema,
|
||||||
_version: z.number().optional(),
|
token_milestone: NotificationEventSchema,
|
||||||
}).optional(),
|
ralph_complete: NotificationEventSchema,
|
||||||
// Voice settings (cross-device sync)
|
subagent_spawn: NotificationEventSchema,
|
||||||
voiceSettings: z.object({
|
subagent_complete: NotificationEventSchema,
|
||||||
apiKey: z.string().max(200).optional(),
|
})
|
||||||
language: z.string().max(20).optional(),
|
.optional(),
|
||||||
keyterms: z.string().max(500).optional(),
|
_version: z.number().optional(),
|
||||||
insertMode: z.string().max(20).optional(),
|
})
|
||||||
}).optional(),
|
.optional(),
|
||||||
}).strict();
|
// Voice settings (cross-device sync)
|
||||||
|
voiceSettings: z
|
||||||
|
.object({
|
||||||
|
apiKey: z.string().max(200).optional(),
|
||||||
|
language: z.string().max(20).optional(),
|
||||||
|
keyterms: z.string().max(500).optional(),
|
||||||
|
insertMode: z.string().max(20).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema for POST /api/sessions/:id/input with length limit
|
* Schema for POST /api/sessions/:id/input with length limit
|
||||||
@@ -381,10 +443,12 @@ export const CpuLimitSchema = z.object({
|
|||||||
export const ModelConfigUpdateSchema = z.record(z.string(), z.unknown());
|
export const ModelConfigUpdateSchema = z.record(z.string(), z.unknown());
|
||||||
|
|
||||||
/** PUT /api/subagent-window-states */
|
/** PUT /api/subagent-window-states */
|
||||||
export const SubagentWindowStatesSchema = z.object({
|
export const SubagentWindowStatesSchema = z
|
||||||
minimized: z.record(z.string(), z.boolean()).optional(),
|
.object({
|
||||||
open: z.array(z.string()).optional(),
|
minimized: z.record(z.string(), z.boolean()).optional(),
|
||||||
}).passthrough();
|
open: z.array(z.string()).optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
/** PUT /api/subagent-parents */
|
/** PUT /api/subagent-parents */
|
||||||
export const SubagentParentMapSchema = z.record(z.string(), z.string());
|
export const SubagentParentMapSchema = z.record(z.string(), z.string());
|
||||||
@@ -423,14 +487,22 @@ export const PushPreferencesUpdateSchema = z.object({
|
|||||||
|
|
||||||
/** POST /api/ralph-loop/start */
|
/** POST /api/ralph-loop/start */
|
||||||
export const RalphLoopStartSchema = z.object({
|
export const RalphLoopStartSchema = z.object({
|
||||||
caseName: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format').optional().default('testcase'),
|
caseName: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')
|
||||||
|
.optional()
|
||||||
|
.default('testcase'),
|
||||||
taskDescription: z.string().min(1).max(100000),
|
taskDescription: z.string().min(1).max(100000),
|
||||||
completionPhrase: z.string().max(100).default('COMPLETE'),
|
completionPhrase: z.string().max(100).default('COMPLETE'),
|
||||||
maxIterations: z.number().int().min(0).max(1000).nullable().default(10),
|
maxIterations: z.number().int().min(0).max(1000).nullable().default(10),
|
||||||
enableRespawn: z.boolean().default(false),
|
enableRespawn: z.boolean().default(false),
|
||||||
planItems: z.array(z.object({
|
planItems: z
|
||||||
content: z.string(),
|
.array(
|
||||||
priority: z.string().optional(),
|
z.object({
|
||||||
enabled: z.boolean().default(true),
|
content: z.string(),
|
||||||
})).optional(),
|
priority: z.string().optional(),
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
+602
-256
File diff suppressed because it is too large.
Load diff
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Reference in new issue
Block a user