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