adjust formatting

This commit is contained in:
Noah Waldner
2026-02-27 11:29:24 +01:00
parent 8b072c6841
commit 8be7c9eb59
37 changed files with 3182 additions and 1522 deletions
+39 -19
View File
@@ -115,7 +115,7 @@ export abstract class AiCheckerBase<
V extends string,
C extends AiCheckerConfigBase,
R extends AiCheckerResultBase<V>,
S extends AiCheckerStateBase<V>
S extends AiCheckerStateBase<V>,
> extends EventEmitter {
protected config: C;
protected sessionId: string;
@@ -283,11 +283,15 @@ export abstract class AiCheckerBase<
if (result.verdict === this.getPositiveVerdict()) {
this.consecutiveErrors = 0;
this._status = 'ready';
this.log(`${this.checkDescription} verdict: ${result.verdict} (${result.durationMs}ms) - ${result.reasoning}`);
this.log(
`${this.checkDescription} verdict: ${result.verdict} (${result.durationMs}ms) - ${result.reasoning}`
);
} else if (result.verdict === this.getNegativeVerdict()) {
this.consecutiveErrors = 0;
this.startCooldown(this.config.cooldownMs);
this.log(`${this.checkDescription} verdict: ${result.verdict} (${result.durationMs}ms) - ${result.reasoning}`);
this.log(
`${this.checkDescription} verdict: ${result.verdict} (${result.durationMs}ms) - ${result.reasoning}`
);
} else {
this.handleError('Unexpected verdict');
}
@@ -369,9 +373,10 @@ 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);
@@ -380,7 +385,10 @@ export abstract class AiCheckerBase<
const shortId = this.sessionId.slice(0, 8);
const timestamp = Date.now();
this.checkTempFile = join(tmpdir(), `${this.tempFilePrefix}-${shortId}-${timestamp}.txt`);
this.checkPromptFile = join(tmpdir(), `${this.tempFilePrefix}-prompt-${shortId}-${timestamp}.txt`);
this.checkPromptFile = join(
tmpdir(),
`${this.tempFilePrefix}-prompt-${shortId}-${timestamp}.txt`
);
this.checkMuxName = `${this.muxNamePrefix}${shortId}`;
// Security: Validate mux name before use in shell commands
@@ -411,16 +419,19 @@ export abstract class AiCheckerBase<
// No existing session, that's fine
}
const muxProcess = childSpawn('tmux', [
'new-session', '-d', '-s', this.checkMuxName,
'bash', '-c', fullCmd
], {
detached: true,
stdio: 'ignore',
});
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
@@ -457,7 +468,9 @@ export abstract class AiCheckerBase<
if (this._status === 'checking' && !this.checkCancelled && !resolved) {
resolved = true; // Mark as resolved first to prevent poll race
this.checkResolve = null;
reject(new Error(`${this.checkDescription} timed out after ${this.config.checkTimeoutMs}ms`));
reject(
new Error(`${this.checkDescription} timed out after ${this.config.checkTimeoutMs}ms`)
);
}
}, this.config.checkTimeoutMs);
});
@@ -474,7 +487,10 @@ export abstract class AiCheckerBase<
// Delegate to subclass for verdict parsing
const parsed = this.parseVerdict(output);
if (!parsed) {
return this.createErrorResult(`Could not parse verdict from: "${output.substring(0, 100)}"`, durationMs);
return this.createErrorResult(
`Could not parse verdict from: "${output.substring(0, 100)}"`,
durationMs
);
}
return this.createResult(parsed.verdict, parsed.reasoning, durationMs);
@@ -530,7 +546,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}`);
@@ -542,7 +560,9 @@ export abstract class AiCheckerBase<
this.config.errorCooldownMs * backoffMultiplier,
5 * 60 * 1000 // Max 5 minutes
);
this.log(`Exponential backoff: ${Math.round(backoffCooldownMs / 1000)}s (error #${this.consecutiveErrors})`);
this.log(
`Exponential backoff: ${Math.round(backoffCooldownMs / 1000)}s (error #${this.consecutiveErrors})`
);
this.startCooldown(backoffCooldownMs);
}
}
+8 -4
View File
@@ -33,13 +33,13 @@ import {
// ========== Types ==========
export interface AiIdleCheckConfig extends AiCheckerConfigBase {}
export type AiIdleCheckConfig = AiCheckerConfigBase;
export type AiCheckVerdict = 'IDLE' | 'WORKING' | 'ERROR';
export interface AiCheckResult extends AiCheckerResultBase<AiCheckVerdict> {}
export type AiCheckResult = AiCheckerResultBase<AiCheckVerdict>;
export interface AiCheckState extends AiCheckerStateBase<AiCheckVerdict> {}
export type AiCheckState = AiCheckerStateBase<AiCheckVerdict>;
// ========== Constants ==========
@@ -170,7 +170,11 @@ export class AiIdleChecker extends AiCheckerBase<
return { verdict: 'ERROR', reasoning, durationMs };
}
protected createResult(verdict: AiCheckVerdict, reasoning: string, durationMs: number): AiCheckResult {
protected createResult(
verdict: AiCheckVerdict,
reasoning: string,
durationMs: number
): AiCheckResult {
return { verdict, reasoning, durationMs };
}
}
+11 -5
View File
@@ -32,13 +32,13 @@ import {
// ========== Types ==========
export interface AiPlanCheckConfig extends AiCheckerConfigBase {}
export type AiPlanCheckConfig = AiCheckerConfigBase;
export type AiPlanCheckVerdict = 'PLAN_MODE' | 'NOT_PLAN_MODE' | 'ERROR';
export interface AiPlanCheckResult extends AiCheckerResultBase<AiPlanCheckVerdict> {}
export type AiPlanCheckResult = AiCheckerResultBase<AiPlanCheckVerdict>;
export interface AiPlanCheckState extends AiCheckerStateBase<AiPlanCheckVerdict> {}
export type AiPlanCheckState = AiCheckerStateBase<AiPlanCheckVerdict>;
// ========== Constants ==========
@@ -105,7 +105,9 @@ export class AiPlanChecker extends AiCheckerBase<
return AI_PLAN_CHECK_PROMPT.replace('{TERMINAL_BUFFER}', terminalBuffer);
}
protected parseVerdict(output: string): { verdict: AiPlanCheckVerdict; reasoning: string } | null {
protected parseVerdict(
output: string
): { verdict: AiPlanCheckVerdict; reasoning: string } | null {
const match = output.match(VERDICT_PATTERN);
if (!match) return null;
@@ -132,7 +134,11 @@ export class AiPlanChecker extends AiCheckerBase<
return { verdict: 'ERROR', reasoning, durationMs };
}
protected createResult(verdict: AiPlanCheckVerdict, reasoning: string, durationMs: number): AiPlanCheckResult {
protected createResult(
verdict: AiPlanCheckVerdict,
reasoning: string,
durationMs: number
): AiPlanCheckResult {
return { verdict, reasoning, durationMs };
}
}
+22 -17
View File
@@ -73,25 +73,27 @@ 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 +247,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 +356,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 +420,10 @@ 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 +448,10 @@ 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 +476,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 +487,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 +505,9 @@ 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 +675,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, '');
}
+57 -42
View File
@@ -28,10 +28,7 @@ program
// ============ 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,11 +80,12 @@ sessionCmd
console.log(' (none)');
} else {
for (const session of sessions) {
const status = session.status === 'idle'
? chalk.green('idle')
: session.status === 'busy'
? chalk.yellow('busy')
: chalk.red(session.status);
const status =
session.status === 'idle'
? chalk.green('idle')
: session.status === 'busy'
? chalk.yellow('busy')
: chalk.red(session.status);
console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status} ${session.workingDir}`);
}
}
@@ -97,7 +95,9 @@ sessionCmd
console.log(chalk.bold('\nStopped Sessions:'));
for (const session of stoppedSessions) {
const name = session.name ? ` (${session.name})` : '';
console.log(` ${chalk.gray(session.id.slice(0, 8))} ${chalk.gray('stopped')}${name} ${session.workingDir}`);
console.log(
` ${chalk.gray(session.id.slice(0, 8))} ${chalk.gray('stopped')}${name} ${session.workingDir}`
);
}
}
@@ -106,15 +106,18 @@ 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'
? chalk.green('idle')
: session.status === 'busy'
? chalk.yellow('busy')
: chalk.red(session.status);
const status =
session.status === 'idle'
? chalk.green('idle')
: session.status === 'busy'
? chalk.yellow('busy')
: chalk.red(session.status);
const name = session.name ? ` (${session.name})` : '';
const mode = session.mode === 'shell' ? chalk.gray(' [shell]') : '';
const cost = session.totalCost ? chalk.gray(` $${session.totalCost.toFixed(4)}`) : '';
console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status}${name}${mode}${cost} ${session.workingDir}`);
console.log(
` ${chalk.cyan(session.id.slice(0, 8))} ${status}${name}${mode}${cost} ${session.workingDir}`
);
}
}
console.log('');
@@ -126,9 +129,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 +146,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>')
@@ -200,12 +198,16 @@ taskCmd
for (const task of tasks) {
const color = statusColors[task.status];
const prompt = task.prompt.slice(0, 40) + (task.prompt.length > 40 ? '...' : '');
console.log(` ${chalk.cyan(task.id.slice(0, 8))} ${color(task.status.padEnd(10))} [${task.priority}] ${prompt}`);
console.log(
` ${chalk.cyan(task.id.slice(0, 8))} ${color(task.status.padEnd(10))} [${task.priority}] ${prompt}`
);
}
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('');
});
@@ -301,7 +303,9 @@ ralphCmd
}
loop.on('taskAssigned', (taskId, sessionId) => {
console.log(chalk.cyan(`→ Task ${taskId.slice(0, 8)} assigned to session ${sessionId.slice(0, 8)}`));
console.log(
chalk.cyan(`→ Task ${taskId.slice(0, 8)} assigned to session ${sessionId.slice(0, 8)}`)
);
});
loop.on('taskCompleted', (taskId) => {
@@ -356,16 +360,20 @@ ralphCmd
function printStats(stats: ReturnType<ReturnType<typeof getRalphLoop>['getStats']>) {
const statusColor =
stats.status === 'running' ? chalk.green :
stats.status === 'paused' ? chalk.yellow :
chalk.gray;
stats.status === 'running'
? chalk.green
: stats.status === 'paused'
? chalk.yellow
: chalk.gray;
console.log(chalk.bold('\nRalph Loop Status:'));
console.log(` 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:'));
@@ -399,7 +407,8 @@ program
const loopStatus = loop.status;
// Use live sessions if available, otherwise fall back to stored state
const activeCount = sessions.length || storedValues.filter((s) => s.status !== 'stopped').length;
const activeCount =
sessions.length || storedValues.filter((s) => s.status !== 'stopped').length;
const idleCount = sessions.length
? sessions.filter((s) => s.isIdle()).length
: storedValues.filter((s) => s.status === 'idle').length;
@@ -423,9 +432,7 @@ program
console.log(` Failed: ${taskCounts.failed}`);
const statusColor =
loopStatus === 'running' ? chalk.green :
loopStatus === 'paused' ? chalk.yellow :
chalk.gray;
loopStatus === 'running' ? chalk.green : loopStatus === 'paused' ? chalk.yellow : chalk.gray;
console.log(chalk.bold('\nRalph Loop:'));
console.log(` Status: ${statusColor(loopStatus)}`);
console.log('');
@@ -481,11 +488,12 @@ program
console.log(' (none)');
} else {
for (const session of sessions) {
const status = session.status === 'idle'
? chalk.green('idle')
: session.status === 'busy'
? chalk.yellow('busy')
: chalk.red(session.status);
const status =
session.status === 'idle'
? chalk.green('idle')
: session.status === 'busy'
? chalk.yellow('busy')
: chalk.red(session.status);
console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status} ${session.workingDir}`);
}
}
@@ -497,20 +505,27 @@ program
.command('web')
.description('Start the web interface')
.option('-p, --port <port>', 'Port to listen on', '3000')
.option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)')
.option(
'--https',
'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)'
)
.action(async (options) => {
const { startWebServer } = await import('./web/server.js');
const port = parseInt(options.port, 10);
const https = !!options.https;
const protocol = https ? 'https' : 'http';
console.log(chalk.cyan(`Starting Codeman web interface on port ${port}${https ? ' (HTTPS)' : ''}...`));
console.log(
chalk.cyan(`Starting Codeman web interface on port ${port}${https ? ' (HTTPS)' : ''}...`)
);
try {
const server = await startWebServer(port, https);
console.log(chalk.green(`\n✓ Web interface running at ${protocol}://localhost:${port}`));
if (https) {
console.log(chalk.yellow(' Note: Accept the self-signed certificate in your browser on first visit'));
console.log(
chalk.yellow(' Note: Accept the self-signed certificate in your browser on first visit')
);
}
console.log(chalk.gray(' Press Ctrl+C to stop\n'));
+15 -3
View File
@@ -141,7 +141,15 @@ export class FileStreamManager extends EventEmitter {
* @returns Result with stream ID on success, error on failure
*/
async createStream(options: CreateStreamOptions): Promise<CreateStreamResult> {
const { sessionId, filePath, workingDir, lines = DEFAULT_TAIL_LINES, onData, onEnd, onError } = options;
const {
sessionId,
filePath,
workingDir,
lines = DEFAULT_TAIL_LINES,
onData,
onEnd,
onError,
} = options;
// Check concurrent stream limit for this session
const currentCount = this.sessionStreamCounts.get(sessionId) || 0;
@@ -170,8 +178,12 @@ 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));
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)
);
return { success: false, error: 'File not found or not accessible' };
}
+4 -1
View File
@@ -71,7 +71,10 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
* Updates env vars in .claude/settings.local.json for the given case path.
* Merges with existing env field; removes vars set to empty string.
*/
export async function updateCaseEnvVars(casePath: string, envVars: Record<string, string>): Promise<void> {
export async function updateCaseEnvVars(
casePath: string,
envVars: Record<string, string>
): Promise<void> {
const claudeDir = join(casePath, '.claude');
if (!existsSync(claudeDir)) {
await mkdir(claudeDir, { recursive: true });
+27 -14
View File
@@ -24,15 +24,7 @@ export interface ImageWatcherEvents {
// ========== Constants ==========
/** Supported image file extensions (lowercase) */
const IMAGE_EXTENSIONS = new Set([
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.bmp',
'.svg',
]);
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
/** Time to wait for file writes to stabilize (ms) */
const STABILITY_THRESHOLD_MS = 500;
@@ -119,7 +111,11 @@ export class ImageWatcher extends EventEmitter {
try {
watcher.close();
} catch (error) {
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
this.emit(
'image:error',
error instanceof Error ? error : new Error(String(error)),
sessionId
);
}
}
this.sessionWatchers.clear();
@@ -171,7 +167,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();
@@ -193,7 +194,11 @@ export class ImageWatcher extends EventEmitter {
this.sessionWatchers.set(sessionId, watcher);
} catch (error) {
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
this.emit(
'image:error',
error instanceof Error ? error : new Error(String(error)),
sessionId
);
}
}
@@ -208,7 +213,11 @@ export class ImageWatcher extends EventEmitter {
try {
watcher.close();
} catch (error) {
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
this.emit(
'image:error',
error instanceof Error ? error : new Error(String(error)),
sessionId
);
}
this.sessionWatchers.delete(sessionId);
}
@@ -313,7 +322,11 @@ export class ImageWatcher extends EventEmitter {
this.emit('image:detected', event);
} catch (error) {
// File may have been deleted between detection and stat
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
this.emit(
'image:error',
error instanceof Error ? error : new Error(String(error)),
sessionId
);
}
}
}
+6 -2
View File
@@ -23,10 +23,14 @@ 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`);
console.error(
`[FATAL] ${MAX_CONSECUTIVE_ERRORS} consecutive unhandled errors — exiting for systemd restart`
);
process.exit(1);
}
}
+8 -1
View File
@@ -7,7 +7,14 @@
*/
import type { EventEmitter } from 'node:events';
import type { ProcessStats, PersistedRespawnConfig, NiceConfig, ClaudeMode, SessionMode, OpenCodeConfig } from './types.js';
import type {
ProcessStats,
PersistedRespawnConfig,
NiceConfig,
ClaudeMode,
SessionMode,
OpenCodeConfig,
} from './types.js';
/**
* Multiplexer session metadata.
+200 -36
View File
@@ -19,10 +19,7 @@ import { Session } from './session.js';
import type { TerminalMultiplexer } from './mux-interface.js';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import {
RESEARCH_AGENT_PROMPT,
PLANNER_PROMPT,
} from './prompts/index.js';
import { RESEARCH_AGENT_PROMPT, PLANNER_PROMPT } from './prompts/index.js';
import { PlanTaskStatus, TddPhase } from './types.js';
// ============================================================================
@@ -170,16 +167,23 @@ 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;
this.outputDir = outputDir;
this.researchModel = modelConfig?.agentTypeOverrides?.explore || modelConfig?.defaultModel || DEFAULT_MODEL;
this.plannerModel = modelConfig?.agentTypeOverrides?.review || modelConfig?.defaultModel || DEFAULT_MODEL;
this.researchModel =
modelConfig?.agentTypeOverrides?.explore || modelConfig?.defaultModel || DEFAULT_MODEL;
this.plannerModel =
modelConfig?.agentTypeOverrides?.review || modelConfig?.defaultModel || DEFAULT_MODEL;
}
private saveAgentOutput(agentType: string, prompt: string, result: unknown, durationMs: number): void {
private saveAgentOutput(
agentType: string,
prompt: string,
result: unknown,
durationMs: number
): void {
if (!this.outputDir) return;
try {
@@ -193,7 +197,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 +235,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 +400,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,
@@ -406,12 +433,22 @@ export class PlanOrchestrator {
this.runningSessions.add(session);
const prompt = RESEARCH_AGENT_PROMPT.replace('{TASK}', taskDescription).replace('{WORKING_DIR}', this.workingDir);
const prompt = RESEARCH_AGENT_PROMPT.replace('{TASK}', taskDescription).replace(
'{WORKING_DIR}',
this.workingDir
);
// 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 +461,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>;
@@ -440,24 +517,58 @@ export class PlanOrchestrator {
findings: {
externalResources: Array.isArray(data.externalResources) ? data.externalResources : [],
codebasePatterns: Array.isArray(data.codebasePatterns) ? data.codebasePatterns : [],
technicalRecommendations: Array.isArray(data.technicalRecommendations) ? data.technicalRecommendations : [],
potentialChallenges: Array.isArray(data.potentialChallenges) ? data.potentialChallenges : [],
technicalRecommendations: Array.isArray(data.technicalRecommendations)
? data.technicalRecommendations
: [],
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 +580,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 +594,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 +612,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 +640,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 +672,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 +689,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
+4 -1
View File
@@ -95,7 +95,10 @@ export class PushSubscriptionStore {
}
/** Update push preferences for a subscription */
updatePreferences(id: string, preferences: Record<string, boolean>): PushSubscriptionRecord | null {
updatePreferences(
id: string,
preferences: Record<string, boolean>
): PushSubscriptionRecord | null {
const sub = this.subscriptions.get(id);
if (!sub) return null;
sub.pushPreferences = preferences;
+1 -4
View File
@@ -282,10 +282,7 @@ export class RalphLoop extends EventEmitter {
this.store.setRalphLoopState({ lastCheckAt: Date.now() });
// Run independent checks in parallel for better performance
await Promise.all([
this.checkTimeouts(),
this.assignTasks(),
]);
await Promise.all([this.checkTimeouts(), this.assignTasks()]);
// Check if we should auto-generate tasks (depends on assignment results)
if (this.autoGenerateTasks && this.shouldGenerateTasks()) {
+233 -149
View File
@@ -151,8 +151,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',
]);
/**
@@ -235,7 +246,8 @@ const TODO_NATIVE_PATTERN = /^[\s⎿]*(☐|☒|◐|✓)\s+([^☐☒◐✓\n]{3,}
*/
const TODO_TASK_CREATED_PATTERN = /✔\s*Task\s*#(\d+)\s*created:\s*(.+)/g;
const TODO_TASK_SUMMARY_PATTERN = /✔\s*#(\d+)\s+(.+)/g;
const TODO_TASK_STATUS_PATTERN = /✔\s*Task\s*#(\d+)\s*updated:\s*status\s*→\s*(in progress|completed|pending)/g;
const TODO_TASK_STATUS_PATTERN =
/✔\s*Task\s*#(\d+)\s*updated:\s*status\s*→\s*(in progress|completed|pending)/g;
/**
* Matches plain checkmark TodoWrite output without task numbers.
@@ -249,9 +261,9 @@ const TODO_PLAIN_CHECKMARK_PATTERN = /✔\s+(.+)/g;
* Prevents false positives from tool invocations and Claude commentary
*/
const TODO_EXCLUDE_PATTERNS = [
/^(?:Bash|Search|Read|Write|Glob|Grep|Edit|Task)\s*\(/i, // Tool invocations
/^(?:I'll |Let me |Now I|First,|Task \d+:|Result:|Error:)/i, // Claude commentary
/^\S+\([^)]+\)$/, // Generic function call pattern
/^(?:Bash|Search|Read|Write|Glob|Grep|Edit|Task)\s*\(/i, // Tool invocations
/^(?:I'll |Let me |Now I|First,|Task \d+:|Result:|Error:)/i, // Claude commentary
/^\S+\([^)]+\)$/, // Generic function call pattern
];
// ---------- Loop Status Patterns ----------
@@ -285,13 +297,15 @@ const CYCLE_PATTERN = /cycle\s*#?(\d+)|respawn cycle #(\d+)/i;
* Examples: "Iteration 5/50", "[5/50]", "iteration #5", "iter. 3 of 10"
* Capture groups: (1,2) for "Iteration X/Y" format, (3,4) for "[X/Y]" format
*/
const ITERATION_PATTERN = /(?:iteration|iter\.?)\s*#?(\d+)(?:\s*(?:\/|of)\s*(\d+))?|\[(\d+)\/(\d+)\]/i;
const ITERATION_PATTERN =
/(?:iteration|iter\.?)\s*#?(\d+)(?:\s*(?:\/|of)\s*(\d+))?|\[(\d+)\/(\d+)\]/i;
/**
* Matches Ralph loop start command or announcement
* Examples: "/ralph-loop:ralph-loop", "Starting Ralph Wiggum loop", "ralph loop beginning"
*/
const RALPH_START_PATTERN = /\/ralph-loop|starting ralph(?:\s+wiggum)?\s+loop|ralph loop (?:started|beginning)/i;
const RALPH_START_PATTERN =
/\/ralph-loop|starting ralph(?:\s+wiggum)?\s+loop|ralph loop (?:started|beginning)/i;
/**
* Matches max iterations configuration
@@ -304,7 +318,8 @@ const MAX_ITERATIONS_PATTERN = /max[_-]?iterations?\s*[=:]\s*(\d+)/i;
* Matches TodoWrite tool usage indicators
* Examples: "TodoWrite", "todos updated", "Todos have been modified"
*/
const TODOWRITE_PATTERN = /TodoWrite|todo(?:s)?\s*(?:updated|written|saved)|Todos have been modified/i;
const TODOWRITE_PATTERN =
/TodoWrite|todo(?:s)?\s*(?:updated|written|saved)|Todos have been modified/i;
// ---------- Task Completion Detection Patterns ----------
@@ -313,7 +328,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 +343,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 ----------
@@ -377,7 +394,8 @@ const RALPH_TESTS_STATUS_PATTERN = /^TESTS_STATUS:\s*(PASSING|FAILING|NOT_RUN)\s
* Matches WORK_TYPE field
* Captures: IMPLEMENTATION | TESTING | DOCUMENTATION | REFACTORING
*/
const RALPH_WORK_TYPE_PATTERN = /^WORK_TYPE:\s*(IMPLEMENTATION|TESTING|DOCUMENTATION|REFACTORING)\s*$/i;
const RALPH_WORK_TYPE_PATTERN =
/^WORK_TYPE:\s*(IMPLEMENTATION|TESTING|DOCUMENTATION|REFACTORING)\s*$/i;
/**
* Matches EXIT_SIGNAL field
@@ -411,48 +429,48 @@ const COMPLETION_INDICATOR_PATTERNS = [
/** P0 (Critical) priority patterns - highest severity issues */
const P0_PRIORITY_PATTERNS = [
/\bP0\b|\(P0\)|:?\s*P0\s*:/, // Explicit P0
/\bCRITICAL\b/, // Critical keyword
/\bBLOCKER\b/, // Blocker
/\bURGENT\b/, // Urgent
/\bSECURITY\b/, // Security issues
/\bCRASH(?:ES|ING)?\b/, // Crash, crashes, crashing
/\bBROKEN\b/, // Broken
/\bDATA\s*LOSS\b/, // Data loss
/\bPRODUCTION\s*(?:DOWN|ISSUE|BUG)\b/, // Production issues
/\bHOTFIX\b/, // Hotfix
/\bSEVERITY\s*1\b/, // Severity 1
/\bP0\b|\(P0\)|:?\s*P0\s*:/, // Explicit P0
/\bCRITICAL\b/, // Critical keyword
/\bBLOCKER\b/, // Blocker
/\bURGENT\b/, // Urgent
/\bSECURITY\b/, // Security issues
/\bCRASH(?:ES|ING)?\b/, // Crash, crashes, crashing
/\bBROKEN\b/, // Broken
/\bDATA\s*LOSS\b/, // Data loss
/\bPRODUCTION\s*(?:DOWN|ISSUE|BUG)\b/, // Production issues
/\bHOTFIX\b/, // Hotfix
/\bSEVERITY\s*1\b/, // Severity 1
];
/** P1 (High) priority patterns - important issues requiring attention */
const P1_PRIORITY_PATTERNS = [
/\bP1\b|\(P1\)|:?\s*P1\s*:/, // Explicit P1
/\bHIGH\s*PRIORITY\b/, // High priority
/\bIMPORTANT\b/, // Important
/\bBUG\b/, // Bug
/\bFIX\b/, // Fix (as task type)
/\bERROR\b/, // Error
/\bFAIL(?:S|ED|ING|URE)?\b/, // Fail variants
/\bREGRESSION\b/, // Regression
/\bMUST\s*(?:HAVE|FIX|DO)\b/, // Must have/fix/do
/\bSEVERITY\s*2\b/, // Severity 2
/\bREQUIRED\b/, // Required
/\bP1\b|\(P1\)|:?\s*P1\s*:/, // Explicit P1
/\bHIGH\s*PRIORITY\b/, // High priority
/\bIMPORTANT\b/, // Important
/\bBUG\b/, // Bug
/\bFIX\b/, // Fix (as task type)
/\bERROR\b/, // Error
/\bFAIL(?:S|ED|ING|URE)?\b/, // Fail variants
/\bREGRESSION\b/, // Regression
/\bMUST\s*(?:HAVE|FIX|DO)\b/, // Must have/fix/do
/\bSEVERITY\s*2\b/, // Severity 2
/\bREQUIRED\b/, // Required
];
/** P2 (Medium) priority patterns - lower priority improvements */
const P2_PRIORITY_PATTERNS = [
/\bP2\b|\(P2\)|:?\s*P2\s*:/, // Explicit P2
/\bNICE\s*TO\s*HAVE\b/, // Nice to have
/\bLOW\s*PRIORITY\b/, // Low priority
/\bREFACTOR\b/, // Refactor
/\bCLEANUP\b/, // Cleanup
/\bIMPROVE(?:MENT)?\b/, // Improve/Improvement
/\bOPTIMIZ(?:E|ATION)\b/, // Optimize/Optimization
/\bCONSIDER\b/, // Consider
/\bWOULD\s*BE\s*NICE\b/, // Would be nice
/\bENHANCE(?:MENT)?\b/, // Enhance/Enhancement
/\bTECH(?:NICAL)?\s*DEBT\b/, // Tech debt
/\bDOCUMENT(?:ATION)?\b/, // Documentation
/\bP2\b|\(P2\)|:?\s*P2\s*:/, // Explicit P2
/\bNICE\s*TO\s*HAVE\b/, // Nice to have
/\bLOW\s*PRIORITY\b/, // Low priority
/\bREFACTOR\b/, // Refactor
/\bCLEANUP\b/, // Cleanup
/\bIMPROVE(?:MENT)?\b/, // Improve/Improvement
/\bOPTIMIZ(?:E|ATION)\b/, // Optimize/Optimization
/\bCONSIDER\b/, // Consider
/\bWOULD\s*BE\s*NICE\b/, // Would be nice
/\bENHANCE(?:MENT)?\b/, // Enhance/Enhancement
/\bTECH(?:NICAL)?\s*DEBT\b/, // Tech debt
/\bDOCUMENT(?:ATION)?\b/, // Documentation
];
// ========== Event Types ==========
@@ -950,7 +968,7 @@ export class RalphTracker extends EventEmitter {
const wasEnabled = this._loopState.enabled;
this._loopState = createInitialRalphTrackerState();
this._loopState.enabled = wasEnabled; // Keep enabled status
this._loopState.enabled = wasEnabled; // Keep enabled status
this._todos.clear();
this._completionPhraseCount.clear();
this._taskNumberToContent.clear();
@@ -1241,7 +1259,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 +1280,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:') ||
lowerContext.includes('completion phrase') ||
lowerContext.includes('output exactly') ||
lowerContext.includes('when done')) {
if (
lowerContext.includes('output:') ||
lowerContext.includes('completion phrase') ||
lowerContext.includes('output exactly') ||
lowerContext.includes('when done')
) {
signals.contextAppropriate = false;
score -= 20;
} else {
@@ -1335,7 +1355,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
@@ -1396,14 +1415,21 @@ export class RalphTracker extends EventEmitter {
// substrings that any pattern could match are present in the data.
// 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('<') && // <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('\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 +1703,9 @@ 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);
@@ -1764,7 +1791,10 @@ export class RalphTracker extends EventEmitter {
this._completionPhraseCount.set(entries[i][0], entries[i][1]);
}
// Always keep the expected phrase if set
if (this._loopState.completionPhrase && !this._completionPhraseCount.has(this._loopState.completionPhrase)) {
if (
this._loopState.completionPhrase &&
!this._completionPhraseCount.has(this._loopState.completionPhrase)
) {
this._completionPhraseCount.set(this._loopState.completionPhrase, 1);
}
}
@@ -1786,7 +1816,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) {
@@ -1870,7 +1900,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 +1913,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 +1926,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',
@@ -1971,14 +2007,16 @@ export class RalphTracker extends EventEmitter {
if (currentIter !== this._lastObservedIteration) {
this._lastIterationChangeTime = Date.now();
this._lastObservedIteration = currentIter;
this._iterationStallWarned = false; // Reset warning on iteration change
this._iterationStallWarned = false; // Reset warning on iteration change
// P1-004: Reset circuit breaker on successful iteration progress
// If we're making progress, the loop is healthy
if (this._circuitBreaker.state === 'HALF_OPEN' ||
this._circuitBreaker.consecutiveNoProgress > 0 ||
this._circuitBreaker.consecutiveSameError > 0 ||
this._circuitBreaker.consecutiveTestsFailure > 0) {
if (
this._circuitBreaker.state === 'HALF_OPEN' ||
this._circuitBreaker.consecutiveNoProgress > 0 ||
this._circuitBreaker.consecutiveSameError > 0 ||
this._circuitBreaker.consecutiveTestsFailure > 0
) {
this._circuitBreaker.consecutiveNoProgress = 0;
this._circuitBreaker.consecutiveSameError = 0;
this._circuitBreaker.lastProgressIteration = currentIter;
@@ -2047,8 +2085,10 @@ export class RalphTracker extends EventEmitter {
// Pre-compute which pattern categories might match (60-75% faster)
const hasCheckbox = line.includes('[');
const hasTodoIndicator = line.includes('Todo:');
const hasNativeCheckbox = line.includes('☐') || line.includes('☒') || line.includes('◐') || line.includes('✓');
const hasStatus = line.includes('(pending)') || line.includes('(in_progress)') || line.includes('(completed)');
const hasNativeCheckbox =
line.includes('☐') || line.includes('☒') || line.includes('◐') || line.includes('✓');
const hasStatus =
line.includes('(pending)') || line.includes('(in_progress)') || line.includes('(completed)');
const hasCheckmark = line.includes('✔');
// Quick check: skip lines that can't possibly contain todos
@@ -2106,7 +2146,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 +2195,12 @@ 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 +2215,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)
@@ -2204,17 +2247,17 @@ export class RalphTracker extends EventEmitter {
switch (icon) {
case '✓':
case '✅':
case '☒': // Claude Code checked checkbox
case '◉': // Filled circle (completed)
case '●': // Solid circle (completed)
case '☒': // Claude Code checked checkbox
case '◉': // Filled circle (completed)
case '●': // Solid circle (completed)
return 'completed';
case '◐': // Half-filled circle (in progress)
case '◐': // Half-filled circle (in progress)
case '⏳':
case '⌛':
case '🔄':
return 'in_progress';
case '☐': // Claude Code empty checkbox
case '○': // Empty circle
case '☐': // Claude Code empty checkbox
case '○': // Empty circle
default:
return 'pending';
}
@@ -2280,10 +2323,10 @@ export class RalphTracker extends EventEmitter {
// Clean content: remove ANSI codes, collapse whitespace, trim
const cleanContent = content
.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '') // Remove ANSI escape codes
.replace(/\s+/g, ' ') // Collapse whitespace
.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '') // Remove ANSI escape codes
.replace(/\s+/g, ' ') // Collapse whitespace
.trim();
if (cleanContent.length < 5) return; // Skip very short content
if (cleanContent.length < 5) return; // Skip very short content
// Parse priority from content
const priority = this.parsePriority(cleanContent);
@@ -2402,8 +2445,8 @@ export class RalphTracker extends EventEmitter {
private normalizeTodoContent(content: string): string {
if (!content) return '';
return content
.replace(/\s+/g, ' ') // Collapse whitespace
.replace(/[^a-zA-Z0-9\s.,!?'"-]/g, '') // Remove special chars (keep punctuation)
.replace(/\s+/g, ' ') // Collapse whitespace
.replace(/[^a-zA-Z0-9\s.,!?'"-]/g, '') // Remove special chars (keep punctuation)
.trim()
.toLowerCase();
}
@@ -2507,7 +2550,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
}
@@ -2609,10 +2652,10 @@ export class RalphTracker extends EventEmitter {
// Default estimates (in ms) based on typical task durations
const defaults = {
trivial: 1 * 60 * 1000, // 1 minute
simple: 3 * 60 * 1000, // 3 minutes
moderate: 10 * 60 * 1000, // 10 minutes
complex: 30 * 60 * 1000, // 30 minutes
trivial: 1 * 60 * 1000, // 1 minute
simple: 3 * 60 * 1000, // 3 minutes
moderate: 10 * 60 * 1000, // 10 minutes
complex: 30 * 60 * 1000, // 30 minutes
};
return defaults[complexity];
}
@@ -2671,9 +2714,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;
@@ -2852,7 +2895,11 @@ export class RalphTracker extends EventEmitter {
* @param config - Partial configuration to apply
* @fires loopUpdate - When loop state changes
*/
configure(config: { enabled?: boolean; completionPhrase?: string; maxIterations?: number }): void {
configure(config: {
enabled?: boolean;
completionPhrase?: string;
maxIterations?: number;
}): void {
if (config.enabled !== undefined) {
this._loopState.enabled = config.enabled;
}
@@ -2983,7 +3030,7 @@ export class RalphTracker extends EventEmitter {
// Ensure enabled flag exists (backwards compatibility)
this._loopState = {
...loopState,
enabled: loopState.enabled ?? false, // Override after spread for backwards compat
enabled: loopState.enabled ?? false, // Override after spread for backwards compat
};
this._todos.clear();
for (const todo of todos) {
@@ -3056,7 +3103,9 @@ export class RalphTracker extends EventEmitter {
if (['IN_PROGRESS', 'COMPLETE', 'BLOCKED'].includes(value)) {
block.status = value as RalphStatusValue;
} else {
parseErrors.push(`Invalid STATUS value: "${value}". Expected: IN_PROGRESS, COMPLETE, or BLOCKED`);
parseErrors.push(
`Invalid STATUS value: "${value}". Expected: IN_PROGRESS, COMPLETE, or BLOCKED`
);
}
matched = true;
}
@@ -3068,7 +3117,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;
}
@@ -3080,7 +3131,9 @@ export class RalphTracker extends EventEmitter {
if (!Number.isNaN(value) && value >= 0) {
block.filesModified = value;
} else {
parseErrors.push(`Invalid FILES_MODIFIED value: "${filesMatch[1]}". Expected: non-negative integer`);
parseErrors.push(
`Invalid FILES_MODIFIED value: "${filesMatch[1]}". Expected: non-negative integer`
);
}
matched = true;
}
@@ -3092,7 +3145,9 @@ export class RalphTracker extends EventEmitter {
if (['PASSING', 'FAILING', 'NOT_RUN'].includes(value)) {
block.testsStatus = value as RalphTestsStatus;
} else {
parseErrors.push(`Invalid TESTS_STATUS value: "${value}". Expected: PASSING, FAILING, or NOT_RUN`);
parseErrors.push(
`Invalid TESTS_STATUS value: "${value}". Expected: PASSING, FAILING, or NOT_RUN`
);
}
matched = true;
}
@@ -3104,7 +3159,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 +3183,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);
}
}
@@ -3266,7 +3323,10 @@ export class RalphTracker extends EventEmitter {
// Track tests failure
if (testsStatus === 'FAILING') {
this._circuitBreaker.consecutiveTestsFailure++;
if (this._circuitBreaker.consecutiveTestsFailure >= 5 && this._circuitBreaker.state !== 'OPEN') {
if (
this._circuitBreaker.consecutiveTestsFailure >= 5 &&
this._circuitBreaker.state !== 'OPEN'
) {
this._circuitBreaker.state = 'OPEN';
this._circuitBreaker.reason = `Tests failing for ${this._circuitBreaker.consecutiveTestsFailure} iterations`;
this._circuitBreaker.reasonCode = 'tests_failing_too_long';
@@ -3319,7 +3379,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,
@@ -3504,7 +3568,9 @@ 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,17 +3601,19 @@ export class RalphTracker extends EventEmitter {
* Initialize plan tasks from generated plan items.
* Called when wizard generates a new plan.
*/
initializePlanTasks(items: Array<{
id?: string;
content: string;
priority?: 'P0' | 'P1' | 'P2' | null;
verificationCriteria?: string;
testCommand?: string;
dependencies?: string[];
tddPhase?: TddPhase;
pairedWith?: string;
complexity?: 'low' | 'medium' | 'high';
}>): void {
initializePlanTasks(
items: Array<{
id?: string;
content: string;
priority?: 'P0' | 'P1' | 'P2' | null;
verificationCriteria?: string;
testCommand?: string;
dependencies?: string[];
tddPhase?: TddPhase;
pairedWith?: string;
complexity?: 'low' | 'medium' | 'high';
}>
): void {
// Save current plan to history before replacing
if (this._planTasks.size > 0) {
this._savePlanToHistory('Plan replaced with new generation');
@@ -3580,11 +3648,14 @@ export class RalphTracker extends EventEmitter {
/**
* Update a specific plan task's status, attempts, or error.
*/
updatePlanTask(taskId: string, update: {
status?: PlanTaskStatus;
error?: string;
incrementAttempts?: boolean;
}): { success: boolean; task?: EnhancedPlanTask; error?: string } {
updatePlanTask(
taskId: string,
update: {
status?: PlanTaskStatus;
error?: string;
incrementAttempts?: boolean;
}
): { success: boolean; task?: EnhancedPlanTask; error?: string } {
const task = this._planTasks.get(taskId);
if (!task) {
return { success: false, error: 'Task not found' };
@@ -3635,7 +3706,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 +3724,10 @@ 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 +3742,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,
@@ -3690,18 +3763,23 @@ export class RalphTracker extends EventEmitter {
const recommendations: string[] = [];
if (stuckTasks.length > 0) {
recommendations.push(`${stuckTasks.length} task(s) are stuck. Consider breaking them into smaller steps.`);
recommendations.push(
`${stuckTasks.length} task(s) are stuck. Consider breaking them into smaller steps.`
);
}
if (summary.failed > summary.completed && summary.total > 5) {
recommendations.push('More tasks have failed than completed. Review approach and consider plan adjustment.');
recommendations.push(
'More tasks have failed than completed. Review approach and consider plan adjustment.'
);
}
const progressPercent = summary.total > 0
? 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.');
recommendations.push(
'Progress is slow. Consider simplifying tasks or reviewing dependencies.'
);
}
if (summary.total > 0 && summary.blocked > summary.total / 3) {
@@ -3749,7 +3827,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 +3835,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 +3845,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 +3889,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 +3932,10 @@ 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
);
}
/**
+390 -213
View File
@@ -41,11 +41,7 @@ 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 { 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,
@@ -542,45 +538,46 @@ export interface RespawnEvents {
/** Default configuration values */
const DEFAULT_CONFIG: RespawnConfig = {
idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback)
updatePrompt: 'write a brief progress summary to CLAUDE.md noting what you accomplished, then continue working.',
interStepDelayMs: 1000, // 1 second between steps
idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback)
updatePrompt:
'write a brief progress summary to CLAUDE.md noting what you accomplished, then continue working.',
interStepDelayMs: 1000, // 1 second between steps
enabled: true,
sendClear: true, // send /clear after update prompt
sendInit: true, // send /init after /clear
completionConfirmMs: 10000, // 10 seconds of silence after completion message
noOutputTimeoutMs: 30000, // 30 seconds fallback if no output at all
autoAcceptPrompts: true, // auto-accept plan mode prompts (not questions)
autoAcceptDelayMs: 8000, // 8 seconds before auto-accepting
aiIdleCheckEnabled: true, // use AI to confirm idle state
sendClear: true, // send /clear after update prompt
sendInit: true, // send /init after /clear
completionConfirmMs: 10000, // 10 seconds of silence after completion message
noOutputTimeoutMs: 30000, // 30 seconds fallback if no output at all
autoAcceptPrompts: true, // auto-accept plan mode prompts (not questions)
autoAcceptDelayMs: 8000, // 8 seconds before auto-accepting
aiIdleCheckEnabled: true, // use AI to confirm idle state
aiIdleCheckModel: 'claude-opus-4-5-20251101',
aiIdleCheckMaxContext: 16000, // ~4k tokens
aiIdleCheckTimeoutMs: 90000, // 90 seconds (thinking can be slow)
aiIdleCheckMaxContext: 16000, // ~4k tokens
aiIdleCheckTimeoutMs: 90000, // 90 seconds (thinking can be slow)
aiIdleCheckCooldownMs: 180000, // 3 minutes after WORKING verdict
aiPlanCheckEnabled: true, // use AI to confirm plan mode before auto-accept
aiPlanCheckEnabled: true, // use AI to confirm plan mode before auto-accept
aiPlanCheckModel: 'claude-opus-4-5-20251101',
aiPlanCheckMaxContext: 8000, // ~2k tokens (plan mode UI is compact)
aiPlanCheckTimeoutMs: 60000, // 60 seconds (thinking can be slow)
aiPlanCheckCooldownMs: 30000, // 30 seconds after NOT_PLAN_MODE
stuckStateDetectionEnabled: true, // detect stuck states
stuckStateWarningMs: 300000, // 5 minutes warning threshold
stuckStateRecoveryMs: 600000, // 10 minutes recovery threshold
maxStuckRecoveries: 3, // max recovery attempts
aiPlanCheckMaxContext: 8000, // ~2k tokens (plan mode UI is compact)
aiPlanCheckTimeoutMs: 60000, // 60 seconds (thinking can be slow)
aiPlanCheckCooldownMs: 30000, // 30 seconds after NOT_PLAN_MODE
stuckStateDetectionEnabled: true, // detect stuck states
stuckStateWarningMs: 300000, // 5 minutes warning threshold
stuckStateRecoveryMs: 600000, // 10 minutes recovery threshold
maxStuckRecoveries: 3, // max recovery attempts
// P2-001: Adaptive timing
adaptiveTimingEnabled: true, // Use adaptive timing based on historical patterns
adaptiveMinConfirmMs: 5000, // Minimum 5 seconds
adaptiveMaxConfirmMs: 30000, // Maximum 30 seconds
adaptiveTimingEnabled: true, // Use adaptive timing based on historical patterns
adaptiveMinConfirmMs: 5000, // Minimum 5 seconds
adaptiveMaxConfirmMs: 30000, // Maximum 30 seconds
// P2-002: Skip-clear optimization
skipClearWhenLowContext: true, // Skip /clear when token count is low
skipClearThresholdPercent: 30, // Skip if below 30% of max context
skipClearWhenLowContext: true, // Skip /clear when token count is low
skipClearThresholdPercent: 30, // Skip if below 30% of max context
// P2-004: Cycle metrics
trackCycleMetrics: true, // Track and persist cycle metrics
trackCycleMetrics: true, // Track and persist cycle metrics
// P2-001: Confidence scoring
minIdleConfidence: 65, // Minimum confidence to trigger idle (0-100)
confidenceWeightCompletion: 40, // Weight for completion message
confidenceWeightSilence: 25, // Weight for output silence
confidenceWeightTokens: 20, // Weight for token stability
confidenceWeightNoWorking: 15, // Weight for working pattern absence
minIdleConfidence: 65, // Minimum confidence to trigger idle (0-100)
confidenceWeightCompletion: 40, // Weight for completion message
confidenceWeightSilence: 25, // Weight for output silence
confidenceWeightTokens: 20, // Weight for token stability
confidenceWeightNoWorking: 15, // Weight for working pattern absence
};
/**
@@ -737,7 +734,10 @@ 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[] = [];
@@ -818,9 +818,9 @@ export class RespawnController extends EventEmitter {
* Used as secondary signals, not primary detection.
*/
private readonly PROMPT_PATTERNS = [
'❯', // Standard prompt
'\u276f', // Unicode variant
'⏵', // Claude Code prompt variant
'❯', // Standard prompt
'\u276f', // Unicode variant
'⏵', // Claude Code prompt variant
];
/**
@@ -829,15 +829,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
];
/**
@@ -912,12 +952,14 @@ export class RespawnController extends EventEmitter {
// Ensure AI check timeouts are positive
if (c.aiIdleCheckTimeoutMs <= 0) c.aiIdleCheckTimeoutMs = DEFAULT_CONFIG.aiIdleCheckTimeoutMs;
if (c.aiIdleCheckCooldownMs < 0) c.aiIdleCheckCooldownMs = DEFAULT_CONFIG.aiIdleCheckCooldownMs;
if (c.aiIdleCheckMaxContext <= 0) c.aiIdleCheckMaxContext = DEFAULT_CONFIG.aiIdleCheckMaxContext;
if (c.aiIdleCheckMaxContext <= 0)
c.aiIdleCheckMaxContext = DEFAULT_CONFIG.aiIdleCheckMaxContext;
// Ensure plan check timeouts are positive
if (c.aiPlanCheckTimeoutMs <= 0) c.aiPlanCheckTimeoutMs = DEFAULT_CONFIG.aiPlanCheckTimeoutMs;
if (c.aiPlanCheckCooldownMs < 0) c.aiPlanCheckCooldownMs = DEFAULT_CONFIG.aiPlanCheckCooldownMs;
if (c.aiPlanCheckMaxContext <= 0) c.aiPlanCheckMaxContext = DEFAULT_CONFIG.aiPlanCheckMaxContext;
if (c.aiPlanCheckMaxContext <= 0)
c.aiPlanCheckMaxContext = DEFAULT_CONFIG.aiPlanCheckMaxContext;
}
/** Wire up AI checker events to controller events (removes existing listeners first to prevent duplicates) */
@@ -999,7 +1041,8 @@ export class RespawnController extends EventEmitter {
const completionMessageDetected = this.completionMessageTime !== null;
const outputSilent = msSinceLastOutput >= this.config.completionConfirmMs;
const tokensStable = msSinceTokenChange >= this.config.completionConfirmMs;
const workingPatternsAbsent = msSinceLastWorking >= RespawnController.MIN_WORKING_PATTERN_ABSENCE_MS;
const workingPatternsAbsent =
msSinceLastWorking >= RespawnController.MIN_WORKING_PATTERN_ABSENCE_MS;
// Calculate confidence level (0-100) using configurable weights
// P2-001: Configurable confidence scoring
@@ -1200,7 +1243,7 @@ export class RespawnController extends EventEmitter {
const prevState = this._state;
this._state = newState;
this.stateEnteredAt = Date.now();
this.stuckStateWarned = false; // Reset warning for new state
this.stuckStateWarned = false; // Reset warning for new state
this.log(`State: ${prevState} → ${newState}`);
this.logAction('state', `${prevState} → ${newState}`);
this.emit('stateChanged', newState, prevState);
@@ -1458,7 +1501,10 @@ export class RespawnController extends EventEmitter {
// Completion message during these states is ignored
break;
default:
assertNever(this._state, `Unhandled RespawnState in completion detection: ${this._state}`);
assertNever(
this._state,
`Unhandled RespawnState in completion detection: ${this._state}`
);
}
return;
}
@@ -1513,12 +1559,16 @@ export class RespawnController extends EventEmitter {
const stripped = data.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '').trim();
if (stripped.length > 2) {
if (this._state === 'ai_checking') {
this.log(`Substantial output during AI check ("${stripped.substring(0, 40)}..."), cancelling`);
this.log(
`Substantial output during AI check ("${stripped.substring(0, 40)}..."), cancelling`
);
this.aiChecker.cancel();
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 +1576,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;
@@ -1706,7 +1756,7 @@ export class RespawnController extends EventEmitter {
if (this._state === 'stopped') return;
const prompt = this.config.kickstartPrompt!;
this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`);
await this.session.writeViaMux(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
await this.session.writeViaMux(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
this.emit('stepSent', 'kickstart', prompt);
this.setState('waiting_kickstart');
this.promptDetected = false;
@@ -1830,12 +1880,20 @@ export class RespawnController extends EventEmitter {
if (durationMs >= this.config.stuckStateRecoveryMs) {
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.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.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;
@@ -1844,8 +1902,13 @@ export class RespawnController extends EventEmitter {
// Check for warning threshold
if (durationMs >= this.config.stuckStateWarningMs && !this.stuckStateWarned) {
this.stuckStateWarned = true;
this.logAction('stuck', `Warning: in state '${this._state}' for ${Math.round(durationMs / 1000)}s`);
this.log(`Stuck-state warning: state '${this._state}' for ${Math.round(durationMs / 1000)}s without progress`);
this.logAction(
'stuck',
`Warning: in state '${this._state}' for ${Math.round(durationMs / 1000)}s`
);
this.log(
`Stuck-state warning: state '${this._state}' for ${Math.round(durationMs / 1000)}s without progress`
);
this.emit('stuckStateWarning', this._state, durationMs);
}
}
@@ -1961,7 +2024,10 @@ export class RespawnController extends EventEmitter {
this.activeTimers.set(name, { name, startedAt: now, durationMs, endsAt });
this.emit('timerStarted', { name, durationMs, endsAt, reason });
this.logAction('timer', `Started ${name}: ${Math.round(durationMs / 1000)}s${reason ? ` (${reason})` : ''}`);
this.logAction(
'timer',
`Started ${name}: ${Math.round(durationMs / 1000)}s${reason ? ` (${reason})` : ''}`
);
return setTimeout(() => {
this.activeTimers.delete(name);
@@ -1989,7 +2055,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,
@@ -2034,11 +2100,13 @@ export class RespawnController extends EventEmitter {
// Always update the rolling window first to maintain continuity
this.workingPatternWindow += data;
if (this.workingPatternWindow.length > RespawnController.WORKING_PATTERN_WINDOW_SIZE) {
this.workingPatternWindow = this.workingPatternWindow.slice(-RespawnController.WORKING_PATTERN_WINDOW_SIZE);
this.workingPatternWindow = this.workingPatternWindow.slice(
-RespawnController.WORKING_PATTERN_WINDOW_SIZE
);
}
// 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));
}
/**
@@ -2081,7 +2149,10 @@ export class RespawnController extends EventEmitter {
this.noOutputTimer = null;
if (this._state === 'watching' || this._state === 'confirming_idle') {
const msSinceOutput = Date.now() - this.lastOutputTime;
this.logAction('detection', `No-output fallback: ${Math.round(msSinceOutput / 1000)}s silence`);
this.logAction(
'detection',
`No-output fallback: ${Math.round(msSinceOutput / 1000)}s silence`
);
// If AI check is disabled or errored out, go directly to idle
if (!this.config.aiIdleCheckEnabled || this.aiChecker.status === 'disabled') {
this.onIdleConfirmed('no-output fallback (AI check disabled)');
@@ -2134,7 +2205,10 @@ export class RespawnController extends EventEmitter {
const tokensStableMet = msSinceTokenChange >= this.config.completionConfirmMs;
if (silenceMet && noWorkingMet && tokensStableMet) {
this.logAction('detection', `Pre-filter passed: silence=${Math.round(msSinceOutput / 1000)}s`);
this.logAction(
'detection',
`Pre-filter passed: silence=${Math.round(msSinceOutput / 1000)}s`
);
this.tryStartAiCheck('pre-filter');
}
}
@@ -2168,14 +2242,18 @@ export class RespawnController extends EventEmitter {
// If AI check is disabled or errored out, fall back to direct idle confirmation
if (!this.config.aiIdleCheckEnabled || this.aiChecker.status === 'disabled') {
this.log(`AI check unavailable (${this.aiChecker.status}), confirming idle directly via: ${reason}`);
this.log(
`AI check unavailable (${this.aiChecker.status}), confirming idle directly via: ${reason}`
);
this.onIdleConfirmed(reason);
return;
}
// 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,67 +2285,80 @@ export class RespawnController extends EventEmitter {
// Get the terminal buffer for analysis
const buffer = this.terminalBuffer.value;
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})`);
return;
}
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})`);
return;
}
// Validate this is the result for the current check (not a stale one)
if (this._currentAiCheckId !== checkId) {
this.log(`AI check result ignored (stale check ID: ${checkId.substring(0, 8)})`);
return;
}
// Validate this is the result for the current check (not a stale one)
if (this._currentAiCheckId !== checkId) {
this.log(`AI check result ignored (stale check ID: ${checkId.substring(0, 8)})`);
return;
}
if (result.verdict === 'IDLE') {
// Cancel any pending confirmation timers - AI has spoken
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: IDLE');
this.completionConfirmTimer = null;
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'AI verdict: IDLE');
this.preFilterTimer = null;
if (result.verdict === 'IDLE') {
// Cancel any pending confirmation timers - AI has spoken
this.cancelTrackedTimer(
'completion-confirm',
this.completionConfirmTimer,
'AI verdict: IDLE'
);
this.completionConfirmTimer = null;
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'AI verdict: IDLE');
this.preFilterTimer = null;
this.logAction('ai-check', `Verdict: IDLE - ${result.reasoning}`);
this.emit('aiCheckCompleted', result);
this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`);
} else if (result.verdict === 'WORKING') {
// Cancel timers and go to cooldown
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: WORKING');
this.completionConfirmTimer = null;
this.logAction('ai-check', `Verdict: IDLE - ${result.reasoning}`);
this.emit('aiCheckCompleted', result);
this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`);
} else if (result.verdict === 'WORKING') {
// Cancel timers and go to cooldown
this.cancelTrackedTimer(
'completion-confirm',
this.completionConfirmTimer,
'AI verdict: WORKING'
);
this.completionConfirmTimer = null;
this.logAction('ai-check', `Verdict: WORKING - ${result.reasoning}`);
this.emit('aiCheckCompleted', result);
this.setState('watching');
this.log(`AI check says WORKING, returning to watching with ${this.config.aiIdleCheckCooldownMs}ms cooldown`);
// Restart timers so the controller retries after cooldown expires
this.startNoOutputTimer();
this.startPreFilterTimer();
} else {
// ERROR verdict
this.logAction('ai-check', `Error: ${result.reasoning}`);
this.emit('aiCheckFailed', result.reasoning);
this.setState('watching');
// Restart timers to allow retry
this.startNoOutputTimer();
this.startPreFilterTimer();
}
}).catch((err) => {
// Validate this is the error for the current check
if (this._currentAiCheckId !== checkId) {
return; // Stale check, ignore error
}
if (this._state === 'stopped') return; // Guard against stopped state
if (this._state === 'ai_checking') {
const errorMsg = err instanceof Error ? err.message : String(err);
this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`);
this.emit('aiCheckFailed', errorMsg);
this.setState('watching');
this.log(`AI check error: ${errorMsg}`);
// Restart timers to allow retry
this.startNoOutputTimer();
this.startPreFilterTimer();
}
});
this.logAction('ai-check', `Verdict: WORKING - ${result.reasoning}`);
this.emit('aiCheckCompleted', result);
this.setState('watching');
this.log(
`AI check says WORKING, returning to watching with ${this.config.aiIdleCheckCooldownMs}ms cooldown`
);
// Restart timers so the controller retries after cooldown expires
this.startNoOutputTimer();
this.startPreFilterTimer();
} else {
// ERROR verdict
this.logAction('ai-check', `Error: ${result.reasoning}`);
this.emit('aiCheckFailed', result.reasoning);
this.setState('watching');
// Restart timers to allow retry
this.startNoOutputTimer();
this.startPreFilterTimer();
}
})
.catch((err) => {
// Validate this is the error for the current check
if (this._currentAiCheckId !== checkId) {
return; // Stale check, ignore error
}
if (this._state === 'stopped') return; // Guard against stopped state
if (this._state === 'ai_checking') {
const errorMsg = err instanceof Error ? err.message : String(err);
this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`);
this.emit('aiCheckFailed', errorMsg);
this.setState('watching');
this.log(`AI check error: ${errorMsg}`);
// Restart timers to allow retry
this.startNoOutputTimer();
this.startPreFilterTimer();
}
});
}
// ========== Auto-Accept Prompt Methods ==========
@@ -2351,7 +2442,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 +2489,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,36 +2509,42 @@ export class RespawnController extends EventEmitter {
this.logAction('plan-check', 'Spawning AI plan checker');
this.emit('planCheckStarted');
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)');
return;
}
if (result.verdict === 'PLAN_MODE') {
// Don't send Enter if state changed (e.g., AI idle check started or respawn cycle began)
if (this._state !== 'watching') {
this.logAction('plan-check', `Verdict: PLAN_MODE but state is ${this._state}, not sending Enter`);
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)');
return;
}
this.emit('planCheckCompleted', result);
this.logAction('plan-check', 'Verdict: PLAN_MODE - sending Enter immediately');
this.sendAutoAcceptEnter();
// No cooldown needed - we're taking action
} else if (result.verdict === 'NOT_PLAN_MODE') {
this.emit('planCheckCompleted', result);
this.logAction('plan-check', `Verdict: NOT_PLAN_MODE - ${result.reasoning}`);
} else {
// ERROR verdict
this.emit('planCheckFailed', result.reasoning);
this.logAction('plan-check', `Error: ${result.reasoning}`);
}
}).catch((err) => {
const errorMsg = err instanceof Error ? err.message : String(err);
this.emit('planCheckFailed', errorMsg);
this.logAction('plan-check', `Failed: ${errorMsg.substring(0, 50)}`);
});
if (result.verdict === 'PLAN_MODE') {
// Don't send Enter if state changed (e.g., AI idle check started or respawn cycle began)
if (this._state !== 'watching') {
this.logAction(
'plan-check',
`Verdict: PLAN_MODE but state is ${this._state}, not sending Enter`
);
return;
}
this.emit('planCheckCompleted', result);
this.logAction('plan-check', 'Verdict: PLAN_MODE - sending Enter immediately');
this.sendAutoAcceptEnter();
// No cooldown needed - we're taking action
} else if (result.verdict === 'NOT_PLAN_MODE') {
this.emit('planCheckCompleted', result);
this.logAction('plan-check', `Verdict: NOT_PLAN_MODE - ${result.reasoning}`);
} else {
// ERROR verdict
this.emit('planCheckFailed', result.reasoning);
this.logAction('plan-check', `Error: ${result.reasoning}`);
}
})
.catch((err) => {
const errorMsg = err instanceof Error ? err.message : String(err);
this.emit('planCheckFailed', errorMsg);
this.logAction('plan-check', `Failed: ${errorMsg.substring(0, 50)}`);
});
}
/**
@@ -2455,7 +2554,9 @@ export class RespawnController extends EventEmitter {
*/
private sendAutoAcceptEnter(): void {
const msSinceOutput = Date.now() - this.lastOutputTime;
this.log(`Auto-accepting plan mode prompt (${msSinceOutput}ms silence, pre-filter + AI confirmed)`);
this.log(
`Auto-accepting plan mode prompt (${msSinceOutput}ms silence, pre-filter + AI confirmed)`
);
// Cancel any pending AI idle checks - we're about to make Claude work
if (this.aiChecker.status === 'checking') {
@@ -2501,7 +2602,11 @@ export class RespawnController extends EventEmitter {
*/
signalStopHook(): void {
// Only process in states where we're watching for idle
if (this._state !== 'watching' && this._state !== 'confirming_idle' && this._state !== 'ai_checking') {
if (
this._state !== 'watching' &&
this._state !== 'confirming_idle' &&
this._state !== 'ai_checking'
) {
this.log(`Stop hook received but ignoring (state is ${this._state})`);
return;
}
@@ -2519,7 +2624,11 @@ export class RespawnController extends EventEmitter {
}
// Cancel completion confirm timer - hook takes precedence
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'Stop hook received');
this.cancelTrackedTimer(
'completion-confirm',
this.completionConfirmTimer,
'Stop hook received'
);
this.completionConfirmTimer = null;
// Cancel pre-filter timer - hook takes precedence
@@ -2540,7 +2649,11 @@ export class RespawnController extends EventEmitter {
*/
signalIdlePrompt(): void {
// Only process in states where we're watching for idle
if (this._state !== 'watching' && this._state !== 'confirming_idle' && this._state !== 'ai_checking') {
if (
this._state !== 'watching' &&
this._state !== 'confirming_idle' &&
this._state !== 'ai_checking'
) {
this.log(`idle_prompt received but ignoring (state is ${this._state})`);
return;
}
@@ -2558,7 +2671,11 @@ export class RespawnController extends EventEmitter {
}
// Cancel all other detection timers - this is definitive
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'idle_prompt received');
this.cancelTrackedTimer(
'completion-confirm',
this.completionConfirmTimer,
'idle_prompt received'
);
this.completionConfirmTimer = null;
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'idle_prompt received');
this.preFilterTimer = null;
@@ -2604,7 +2721,10 @@ export class RespawnController extends EventEmitter {
}
// No new output - confirm idle via hook signal
this.logAction('hook', `${hookType} confirmed after ${RespawnController.HOOK_CONFIRM_DELAY_MS}ms`);
this.logAction(
'hook',
`${hookType} confirmed after ${RespawnController.HOOK_CONFIRM_DELAY_MS}ms`
);
this.onIdleConfirmed(`${hookType} hook (confirmed)`);
},
`confirming ${hookType} hook`
@@ -2715,7 +2835,10 @@ export class RespawnController extends EventEmitter {
const msSinceOutput = Date.now() - this.lastOutputTime;
if (msSinceOutput >= this.config.completionConfirmMs) {
this.logAction('step', `${step} confirmed after ${Math.round(msSinceOutput / 1000)}s silence`);
this.logAction(
'step',
`${step} confirmed after ${Math.round(msSinceOutput / 1000)}s silence`
);
// Proceed with the step completion
switch (step) {
@@ -2755,7 +2878,9 @@ export class RespawnController extends EventEmitter {
// Safety check: if Session thinks it's still working, don't trigger idle
// This catches cases where our detection missed working patterns
if (this.session.isWorking) {
this.log(`Idle confirmation rejected - Session reports isWorking=true (reason was: ${reason})`);
this.log(
`Idle confirmation rejected - Session reports isWorking=true (reason was: ${reason})`
);
this.logAction('detection', 'Rejected: Session still working');
this.setState('watching');
this.startNoOutputTimer();
@@ -2765,11 +2890,13 @@ export class RespawnController extends EventEmitter {
this.log(`Idle confirmed via: ${reason}`);
const status = this.getDetectionStatus();
this.log(`Detection status: confidence=${status.confidenceLevel}%, ` +
`completion=${status.completionMessageDetected}, ` +
`silent=${status.outputSilent}, ` +
`tokensStable=${status.tokensStable}, ` +
`noWorking=${status.workingPatternsAbsent}`);
this.log(
`Detection status: confidence=${status.confidenceLevel}%, ` +
`completion=${status.completionMessageDetected}, ` +
`silent=${status.outputSilent}, ` +
`tokensStable=${status.tokensStable}, ` +
`noWorking=${status.workingPatternsAbsent}`
);
// ========== Agent Teams Integration ==========
// Check if session has active teammates — don't respawn while team is working
@@ -2777,7 +2904,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 +2922,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;
@@ -2803,8 +2936,14 @@ export class RespawnController extends EventEmitter {
const statusBlock = ralphTracker?.lastStatusBlock;
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.logAction(
'ralph',
`Exit signal detected: ${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;
@@ -2813,8 +2952,14 @@ export class RespawnController extends EventEmitter {
// Check if STATUS=BLOCKED - trigger circuit breaker
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.logAction(
'ralph',
`Claude reported BLOCKED: ${statusBlock.recommendation || 'Needs human intervention'}`
);
this.emit('respawnBlocked', {
reason: 'status_blocked',
details: statusBlock.recommendation || 'Needs human intervention',
});
this.setState('watching');
return;
}
@@ -2834,7 +2979,11 @@ export class RespawnController extends EventEmitter {
*/
private onIdleDetected(): void {
// Accept watching, confirming_idle, and ai_checking states
if (this._state !== 'watching' && this._state !== 'confirming_idle' && this._state !== 'ai_checking') {
if (
this._state !== 'watching' &&
this._state !== 'confirming_idle' &&
this._state !== 'ai_checking'
) {
return;
}
@@ -2846,7 +2995,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;
}
@@ -2907,7 +3059,7 @@ export class RespawnController extends EventEmitter {
this.logAction('ralph', `Using RECOMMENDATION: ${rec.substring(0, 50)}...`);
}
const input = updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI
const input = updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI
this.logAction('command', `Sending: "${updatePrompt.substring(0, 50)}..."`);
await this.session.writeViaMux(input);
this.emit('stepSent', 'update', updatePrompt);
@@ -2937,7 +3089,7 @@ export class RespawnController extends EventEmitter {
this.stepTimer = null;
if (this._state === 'stopped') return;
this.logAction('command', 'Sending: /clear');
await this.session.writeViaMux('/clear\r'); // \r triggers Enter in Ink/Claude CLI
await this.session.writeViaMux('/clear\r'); // \r triggers Enter in Ink/Claude CLI
this.emit('stepSent', 'clear', '/clear');
this.setState('waiting_clear');
this.promptDetected = false;
@@ -2981,7 +3133,7 @@ export class RespawnController extends EventEmitter {
this.stepTimer = null;
if (this._state === 'stopped') return;
this.logAction('command', 'Sending: /init');
await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI
await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI
this.emit('stepSent', 'init', '/init');
this.setState('waiting_init');
this.promptDetected = false;
@@ -3052,9 +3204,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 +3221,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,
@@ -3223,7 +3383,9 @@ export class RespawnController extends EventEmitter {
const usagePercent = (currentTokens / maxContext) * 100;
if (usagePercent < thresholdPercent) {
this.log(`Skip-clear optimization: ${usagePercent.toFixed(1)}% < ${thresholdPercent}% threshold`);
this.log(
`Skip-clear optimization: ${usagePercent.toFixed(1)}% < ${thresholdPercent}% threshold`
);
this.logAction('optimization', `Skipping /clear (${usagePercent.toFixed(1)}% context used)`);
return true;
}
@@ -3276,7 +3438,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 +3461,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 +3497,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 +3511,8 @@ 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,18 +3555,18 @@ 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(
components.cycleSuccess * weights.cycleSuccess +
components.circuitBreaker * weights.circuitBreaker +
components.iterationProgress * weights.iterationProgress +
components.aiChecker * weights.aiChecker +
components.stuckRecovery * weights.stuckRecovery
components.circuitBreaker * weights.circuitBreaker +
components.iterationProgress * weights.iterationProgress +
components.aiChecker * weights.aiChecker +
components.stuckRecovery * weights.stuckRecovery
);
// Determine status
@@ -3446,10 +3609,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;
}
}
@@ -3497,19 +3664,27 @@ export class RespawnController extends EventEmitter {
const recommendations: string[] = [];
if (components.cycleSuccess < 70) {
recommendations.push('Cycle success rate is low. Check for recurring errors or stuck states.');
recommendations.push(
'Cycle success rate is low. Check for recurring errors or stuck states.'
);
}
if (components.circuitBreaker < 50) {
recommendations.push('Circuit breaker is open or half-open. Review recent errors and consider manual reset.');
recommendations.push(
'Circuit breaker is open or half-open. Review recent errors and consider manual reset.'
);
}
if (components.iterationProgress < 50) {
recommendations.push('Iteration progress has stalled. Check if Claude is stuck on a task.');
}
if (components.aiChecker < 50) {
recommendations.push('AI idle checker has errors. May need to check Claude CLI availability.');
recommendations.push(
'AI idle checker has errors. May need to check Claude CLI availability.'
);
}
if (components.stuckRecovery < 50) {
recommendations.push('Multiple stuck-state recoveries occurred. Consider increasing timeouts.');
recommendations.push(
'Multiple stuck-state recoveries occurred. Consider increasing timeouts.'
);
}
if (recommendations.length === 0) {
@@ -3527,8 +3702,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.`;
+10 -18
View File
@@ -139,13 +139,10 @@ export class RunSummaryTracker {
// Record state transition
if (oldState && oldState !== newState) {
this.stats.stateTransitions++;
this.addEvent(
'respawn_state_change',
'info',
`State: ${oldState} → ${newState}`,
details,
{ from: oldState, to: newState }
);
this.addEvent('respawn_state_change', 'info', `State: ${oldState} → ${newState}`, details, {
from: oldState,
to: newState,
});
}
// Update state tracking
@@ -206,7 +203,8 @@ export class RunSummaryTracker {
}
// Check for milestone
const currentMilestone = Math.floor(total / TOKEN_MILESTONE_INTERVAL) * TOKEN_MILESTONE_INTERVAL;
const currentMilestone =
Math.floor(total / TOKEN_MILESTONE_INTERVAL) * TOKEN_MILESTONE_INTERVAL;
if (currentMilestone > this.lastTokenMilestone && currentMilestone > 0) {
this.lastTokenMilestone = currentMilestone;
this.addEvent(
@@ -264,13 +262,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 +272,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',
+2 -5
View File
@@ -102,7 +102,7 @@ export class SessionManager extends EventEmitter {
// Create a new lock promise that others will wait on
// Define unlock first to ensure it's always in scope before promise assignment
let unlock!: () => void;
const lockPromise = new Promise<void>(resolve => {
const lockPromise = new Promise<void>((resolve) => {
unlock = resolve;
});
this._sessionCreationLock = lockPromise;
@@ -196,9 +196,7 @@ export class SessionManager extends EventEmitter {
* Uses Promise.allSettled to ensure all sessions are stopped even if some fail.
*/
async stopAllSessions(): Promise<void> {
const stopPromises = Array.from(this.sessions.keys()).map((id) =>
this.stopSession(id)
);
const stopPromises = Array.from(this.sessions.keys()).map((id) => this.stopSession(id));
const results = await Promise.allSettled(stopPromises);
// Log any failures but don't throw - best effort cleanup
for (const result of results) {
@@ -292,4 +290,3 @@ export function getSessionManager(): SessionManager {
}
return managerInstance;
}
+207 -93
View File
@@ -18,7 +18,19 @@
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';
@@ -96,11 +108,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 +201,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)
@@ -247,7 +260,10 @@ export class Session extends EventEmitter {
private _status: SessionStatus = 'idle';
private _currentTaskId: string | null = null;
// Use BufferAccumulator for hot-path buffers to reduce GC pressure
private _terminalBuffer = new BufferAccumulator(MAX_TERMINAL_BUFFER_SIZE, TERMINAL_BUFFER_TRIM_SIZE);
private _terminalBuffer = new BufferAccumulator(
MAX_TERMINAL_BUFFER_SIZE,
TERMINAL_BUFFER_TRIM_SIZE
);
private _textOutput = new BufferAccumulator(MAX_TEXT_OUTPUT_SIZE, TEXT_OUTPUT_TRIM_SIZE);
private _errorBuffer: string = '';
private _lastActivityAt: number;
@@ -258,7 +274,7 @@ export class Session extends EventEmitter {
private _lineBufferFlushTimer: NodeJS.Timeout | null = null;
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
private rejectPromise: ((reason: Error) => void) | null = null;
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
private _isWorking: boolean = false;
private _lastPromptTime: number = 0;
private activityTimeout: NodeJS.Timeout | null = null;
@@ -356,7 +372,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,26 +383,28 @@ 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> & {
workingDir: string;
mode?: SessionMode;
name?: string;
/** Terminal multiplexer instance (tmux) */
mux?: TerminalMultiplexer;
/** Whether to use multiplexer wrapping */
useMux?: boolean;
/** Existing mux session for restored sessions */
muxSession?: MuxSession;
niceConfig?: NiceConfig; // Nice prioritying configuration
/** Claude model override (e.g., 'opus', 'sonnet', 'haiku') */
model?: string;
/** Claude CLI startup permission mode */
claudeMode?: ClaudeMode;
/** Comma-separated allowed tools (for 'allowedTools' mode) */
allowedTools?: string;
/** OpenCode configuration (only for mode === 'opencode') */
openCodeConfig?: OpenCodeConfig;
}) {
constructor(
config: Partial<SessionConfig> & {
workingDir: string;
mode?: SessionMode;
name?: string;
/** Terminal multiplexer instance (tmux) */
mux?: TerminalMultiplexer;
/** Whether to use multiplexer wrapping */
useMux?: boolean;
/** Existing mux session for restored sessions */
muxSession?: MuxSession;
niceConfig?: NiceConfig; // Nice prioritying configuration
/** Claude model override (e.g., 'opus', 'sonnet', 'haiku') */
model?: string;
/** Claude CLI startup permission mode */
claudeMode?: ClaudeMode;
/** Comma-separated allowed tools (for 'allowedTools' mode) */
allowedTools?: string;
/** OpenCode configuration (only for mode === 'opencode') */
openCodeConfig?: OpenCodeConfig;
}
) {
super();
this.setMaxListeners(25);
@@ -472,7 +492,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 +692,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 +747,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 +774,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,14 +940,20 @@ 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) {
try {
// Verify stale mux session — tmux may have been destroyed (e.g., killed externally)
if (this._muxSession && !this._mux.muxSessionExists(this._muxSession.muxName)) {
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
console.log(
'[Session] Stale mux session detected (tmux gone):',
this._muxSession.muxName
);
this._muxSession = null;
}
@@ -928,16 +963,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 +988,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);
@@ -962,13 +1007,21 @@ export class Session extends EventEmitter {
this.ptyProcess = pty.spawn(
this._mux.getAttachCommand(),
this._mux.getAttachArgs(this._muxSession!.muxName),
{
name: 'xterm-256color',
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 },
});
{
name: 'xterm-256color',
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,
},
}
);
// Set claudeSessionId immediately since we passed --session-id to Claude
// The mux manager passes --session-id ${sessionId} to Claude
@@ -1010,9 +1063,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 +1132,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 +1301,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,14 +1346,20 @@ 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) {
try {
// Verify stale mux session — tmux may have been destroyed externally
if (this._muxSession && !this._mux.muxSessionExists(this._muxSession.muxName)) {
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
console.log(
'[Session] Stale mux session detected (tmux gone):',
this._muxSession.muxName
);
this._muxSession = null;
}
@@ -1308,12 +1367,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 +1387,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
}
@@ -1333,13 +1403,21 @@ export class Session extends EventEmitter {
this.ptyProcess = pty.spawn(
this._mux.getAttachCommand(),
this._mux.getAttachArgs(this._muxSession!.muxName),
{
name: 'xterm-256color',
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 },
});
{
name: 'xterm-256color',
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,
},
}
);
} catch (spawnErr) {
console.error('[Session] Failed to spawn PTY for shell mux attachment:', spawnErr);
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
@@ -1474,7 +1552,7 @@ export class Session extends EventEmitter {
this._messages = [];
this._lineBuffer = '';
this._lastActivityAt = Date.now();
this._promptResolved = false; // Reset race condition guard
this._promptResolved = false; // Reset race condition guard
this.resolvePromise = resolve;
this.rejectPromise = reject;
@@ -1482,13 +1560,18 @@ 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',
'--output-format',
'stream-json',
];
if (model) {
args.push('--model', model);
@@ -1517,7 +1600,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 +1647,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';
@@ -1574,18 +1660,24 @@ export class Session extends EventEmitter {
} else if (exitCode !== 0 || (resultMsg && resultMsg.is_error)) {
this._status = 'error';
if (reject) {
reject(new Error(this._errorBuffer || this._textOutput.value || 'Process exited with error'));
reject(
new Error(
this._errorBuffer || this._textOutput.value || 'Process exited with error'
)
);
}
} 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 +1741,9 @@ 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 +1782,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 +1925,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;
@@ -1837,7 +1935,9 @@ export class Session extends EventEmitter {
// Safety: Absolute maximum tokens per session
if (tokenCount > MAX_SESSION_TOKENS) {
console.warn(`[Session ${this.id}] Rejected token count exceeding max: ${tokenCount} > ${MAX_SESSION_TOKENS}`);
console.warn(
`[Session ${this.id}] Rejected token count exceeding max: ${tokenCount} > ${MAX_SESSION_TOKENS}`
);
return;
}
@@ -1850,7 +1950,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 +1976,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;
@@ -1947,7 +2054,9 @@ export class Session extends EventEmitter {
const totalTokens = this._totalInputTokens + this._totalOutputTokens;
if (totalTokens >= this._autoCompactThreshold) {
this._isCompacting = true;
console.log(`[Session] Auto-compact triggered: ${totalTokens} tokens >= ${this._autoCompactThreshold} threshold`);
console.log(
`[Session] Auto-compact triggered: ${totalTokens} tokens >= ${this._autoCompactThreshold} threshold`
);
// Wait for Claude to be idle before compacting
const checkAndCompact = async () => {
@@ -1967,7 +2076,7 @@ export class Session extends EventEmitter {
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)
@@ -2001,7 +2110,9 @@ export class Session extends EventEmitter {
const totalTokens = this._totalInputTokens + this._totalOutputTokens;
if (totalTokens >= this._autoClearThreshold) {
this._isClearing = true;
console.log(`[Session] Auto-clear triggered: ${totalTokens} tokens >= ${this._autoClearThreshold} threshold`);
console.log(
`[Session] Auto-clear triggered: ${totalTokens} tokens >= ${this._autoClearThreshold} threshold`
);
// Wait for Claude to be idle before clearing
const checkAndClear = async () => {
@@ -2121,7 +2232,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);
});
@@ -2256,11 +2367,14 @@ export class Session extends EventEmitter {
try {
this.ptyProcess.kill();
} catch (err) {
console.warn('[Session] Failed to send SIGTERM to PTY process (may already be dead):', err);
console.warn(
'[Session] Failed to send SIGTERM to PTY process (may already be dead):',
err
);
}
// 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 {
+66 -15
View File
@@ -14,11 +14,28 @@
* @module state-store
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync, copyFileSync } from 'node:fs';
import {
readFileSync,
writeFileSync,
existsSync,
mkdirSync,
renameSync,
unlinkSync,
copyFileSync,
} from 'node:fs';
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) */
@@ -178,7 +195,9 @@ export class StateStore {
// Circuit breaker: stop attempting writes after too many failures
if (this.circuitBreakerOpen) {
console.warn('[StateStore] Circuit breaker open - skipping save (too many consecutive failures)');
console.warn(
'[StateStore] Circuit breaker open - skipping save (too many consecutive failures)'
);
return;
}
@@ -192,7 +211,10 @@ export class StateStore {
try {
json = JSON.stringify(this.state);
} catch (err) {
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
console.error(
'[StateStore] Failed to serialize state (circular reference or invalid data):',
err
);
this.consecutiveSaveFailures++;
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
@@ -255,7 +277,9 @@ export class StateStore {
}
if (this.circuitBreakerOpen) {
console.warn('[StateStore] Circuit breaker open - skipping save (too many consecutive failures)');
console.warn(
'[StateStore] Circuit breaker open - skipping save (too many consecutive failures)'
);
return;
}
@@ -268,7 +292,10 @@ export class StateStore {
try {
json = JSON.stringify(this.state);
} catch (err) {
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
console.error(
'[StateStore] Failed to serialize state (circular reference or invalid data):',
err
);
this.consecutiveSaveFailures++;
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
@@ -299,7 +326,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 +415,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 }> = [];
@@ -476,12 +510,16 @@ export class StateStore {
addToGlobalStats(inputTokens: number, outputTokens: number, cost: number): void {
// Sanity check: reject absurdly large values
if (inputTokens > MAX_SESSION_TOKENS || outputTokens > MAX_SESSION_TOKENS) {
console.warn(`[StateStore] Rejected absurd global stats: input=${inputTokens}, output=${outputTokens}`);
console.warn(
`[StateStore] Rejected absurd global stats: input=${inputTokens}, output=${outputTokens}`
);
return;
}
// 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 +543,12 @@ 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;
@@ -588,7 +631,9 @@ export class StateStore {
// Claude's context window is ~200k, so 1M per recording is already very generous
const MAX_TOKENS_PER_RECORDING = 1_000_000;
if (inputTokens > MAX_TOKENS_PER_RECORDING || outputTokens > MAX_TOKENS_PER_RECORDING) {
console.warn(`[StateStore] Rejected absurd token values: input=${inputTokens}, output=${outputTokens}`);
console.warn(
`[StateStore] Rejected absurd token values: input=${inputTokens}, output=${outputTokens}`
);
return;
}
@@ -602,7 +647,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,
@@ -695,7 +740,10 @@ export class StateStore {
try {
json = JSON.stringify(data);
} catch (err) {
console.error('[StateStore] Failed to serialize Ralph state (circular reference or invalid data):', err);
console.error(
'[StateStore] Failed to serialize Ralph state (circular reference or invalid data):',
err
);
// Keep dirty flag true for retry - don't throw, let caller continue
return;
}
@@ -713,7 +761,10 @@ export class StateStore {
unlinkSync(tempPath);
}
} catch (cleanupErr) {
console.warn('[StateStore] Failed to cleanup temp file during Ralph state save error:', cleanupErr);
console.warn(
'[StateStore] Failed to cleanup temp file during Ralph state save error:',
cleanupErr
);
}
// Don't throw - let caller continue, retry on next save
}
+147 -64
View File
@@ -77,16 +77,18 @@ export interface SubagentTranscriptEntry {
input_tokens?: number;
output_tokens?: number;
};
content: string | Array<{
type: 'text' | 'tool_use' | 'tool_result';
text?: string;
name?: string;
id?: string; // tool_use_id for tool_use blocks
tool_use_id?: string; // tool_use_id for tool_result blocks
input?: Record<string, unknown>;
content?: string | Array<{ type: string; text?: string }>; // tool_result content
is_error?: boolean; // For tool_result errors
}>;
content:
| string
| Array<{
type: 'text' | 'tool_use' | 'tool_result';
text?: string;
name?: string;
id?: string; // tool_use_id for tool_use blocks
tool_use_id?: string; // tool_use_id for tool_result blocks
input?: Record<string, unknown>;
content?: string | Array<{ type: string; text?: string }>; // tool_result content
is_error?: boolean; // For tool_result errors
}>;
};
data?: {
type: string;
@@ -135,11 +137,11 @@ const MAX_TRACKED_AGENTS = 500; // Maximum agents to track (LRU eviction when ex
// Internal Claude Code agent patterns to filter out (not real user-initiated subagents)
const INTERNAL_AGENT_PATTERNS = [
/^\[?SUGGESTION MODE/i, // Claude Code's internal suggestion mode
/^Suggest what user might/i, // Suggestion mode prompt variant
/aprompt/i, // Internal prompt agent (anywhere in string)
/^a\s?prompt/i, // Variants of internal prompt agent
/^prompt$/i, // Just "prompt"
/^\[?SUGGESTION MODE/i, // Claude Code's internal suggestion mode
/^Suggest what user might/i, // Suggestion mode prompt variant
/aprompt/i, // Internal prompt agent (anywhere in string)
/^a\s?prompt/i, // Variants of internal prompt agent
/^prompt$/i, // Just "prompt"
];
// Minimum description length - very short descriptions are likely internal or malformed
@@ -168,14 +170,20 @@ export class SubagentWatcher extends EventEmitter {
private knownSubagentDirs = new Set<string>();
// Map of agentId -> Map of toolUseId -> { toolName, timestamp } (for linking tool_result to tool_call)
// Includes timestamp for TTL-based cleanup of orphaned entries
private pendingToolCalls = new Map<string, Map<string, { toolName: string; timestamp: number }>>();
private pendingToolCalls = new Map<
string,
Map<string, { toolName: string; timestamp: number }>
>();
// Guard to prevent concurrent liveness checks (prevents duplicate completed events)
private _isCheckingLiveness = false;
// Counter for throttling full directory scans (only scan every FULL_SCAN_EVERY_N_POLLS)
private _pollCount = 0;
// Short-lived cache for parsed parent transcript descriptions (TTL: 5s)
// Key: "{projectHash}/{sessionId}", Value: { descriptions: Map<agentId, description>, timestamp }
private parentDescriptionCache = new Map<string, { descriptions: Map<string, string>; timestamp: number }>();
private parentDescriptionCache = new Map<
string,
{ descriptions: Map<string, string>; timestamp: number }
>();
// Store error handlers for FSWatchers to enable proper cleanup (prevent memory leaks)
private dirWatcherErrorHandlers = new Map<string, (error: Error) => void>();
private fileWatcherErrorHandlers = new Map<string, (error: Error) => void>();
@@ -194,7 +202,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 +271,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,26 +330,44 @@ 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) => {
let environ = '';
let cmdline = '';
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 });
}
}));
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 */
}
// 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) {
for (const [_agentId, info] of this.agentInfo) {
if (info.status !== 'active' && info.status !== 'idle') continue;
if (procInfo.environ.includes(info.sessionId) || procInfo.cmdline.includes(info.sessionId)) {
if (
procInfo.environ.includes(info.sessionId) ||
procInfo.cmdline.includes(info.sessionId)
) {
info.pid = pid;
break; // Each PID belongs to at most one agent
}
@@ -567,9 +593,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);
}
/**
@@ -805,7 +829,10 @@ export class SubagentWatcher extends EventEmitter {
if (typeof entry.message.content === 'string') {
const text = entry.message.content.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, ' ')}`);
}
} else {
@@ -815,7 +842,10 @@ 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, ' ')}`);
}
}
@@ -826,14 +856,18 @@ export class SubagentWatcher extends EventEmitter {
if (typeof entry.message.content === 'string') {
const text = entry.message.content.trim();
if (text.length < 100 && !text.includes('{')) {
lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, USER_TEXT_PREVIEW_LENGTH)}`);
lines.push(
`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, USER_TEXT_PREVIEW_LENGTH)}`
);
}
} else {
const firstContent = entry.message.content[0];
if (firstContent?.type === 'text' && firstContent.text) {
const text = firstContent.text.trim();
if (text.length < 100 && !text.includes('{')) {
lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, USER_TEXT_PREVIEW_LENGTH)}`);
lines.push(
`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, USER_TEXT_PREVIEW_LENGTH)}`
);
}
}
}
@@ -932,14 +966,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());
@@ -982,7 +1020,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 +1050,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 +1067,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);
@@ -1065,7 +1115,11 @@ export class SubagentWatcher extends EventEmitter {
/**
* Watch a subagent directory for new/updated files
*/
private async watchSubagentDir(dir: string, projectHash: string, sessionId: string): Promise<void> {
private async watchSubagentDir(
dir: string,
projectHash: string,
sessionId: string
): Promise<void> {
if (this.knownSubagentDirs.has(dir)) return;
this.knownSubagentDirs.add(dir);
@@ -1123,7 +1177,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', '');
@@ -1147,7 +1206,11 @@ export class SubagentWatcher extends EventEmitter {
// Extract description - prefer reading from parent transcript (most reliable)
// The parent transcript has the exact Task tool call with description parameter
let description = await this.extractDescriptionFromParentTranscript(projectHash, sessionId, agentId);
let description = await this.extractDescriptionFromParentTranscript(
projectHash,
sessionId,
agentId
);
// Fallback: extract a smart title from the subagent's prompt if parent lookup failed
if (!description) {
@@ -1192,12 +1255,14 @@ export class SubagentWatcher extends EventEmitter {
this.emit('subagent:discovered', info);
// Read existing content
this.tailFile(filePath, agentId, sessionId, 0).then((position) => {
this.filePositions.set(filePath, position);
}).catch((err) => {
// Log but don't throw - non-critical background operation
console.warn(`[SubagentWatcher] Failed to read initial content for ${agentId}:`, err);
});
this.tailFile(filePath, agentId, sessionId, 0)
.then((position) => {
this.filePositions.set(filePath, position);
})
.catch((err) => {
// Log but don't throw - non-critical background operation
console.warn(`[SubagentWatcher] Failed to read initial content for ${agentId}:`, err);
});
// Watch for changes
try {
@@ -1251,7 +1316,11 @@ export class SubagentWatcher extends EventEmitter {
// Handle watcher errors to prevent unhandled exceptions
// Store handler reference for proper cleanup
const errorHandler = (error: Error) => {
this.emit('subagent:error', error instanceof Error ? error : new Error(String(error)), agentId);
this.emit(
'subagent:error',
error instanceof Error ? error : new Error(String(error)),
agentId
);
watcher.close();
this.fileWatcherErrorHandlers.delete(filePath);
this.fileWatchers.delete(filePath);
@@ -1314,7 +1383,11 @@ export class SubagentWatcher extends EventEmitter {
/**
* Process a transcript entry and emit appropriate events
*/
private async processEntry(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): Promise<void> {
private async processEntry(
entry: SubagentTranscriptEntry,
agentId: string,
sessionId: string
): Promise<void> {
const info = this.agentInfo.get(agentId);
// Extract model from assistant messages (first one sets the model)
@@ -1378,9 +1451,11 @@ 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
? `${entry.data.hookEvent}:${entry.data.tool_name}`
: undefined),
hookName:
entry.data.hookName ||
(entry.data.hookEvent && entry.data.tool_name
? `${entry.data.hookEvent}:${entry.data.tool_name}`
: undefined),
};
this.emit('subagent:progress', progress);
} else if (entry.type === 'assistant' && entry.message?.content) {
@@ -1526,13 +1601,15 @@ export class SubagentWatcher extends EventEmitter {
/**
* Extract text content from tool_result content field
*/
private extractToolResultContent(content: string | Array<{ type: string; text?: string }> | undefined): string {
private extractToolResultContent(
content: string | Array<{ type: string; text?: string }> | undefined
): string {
if (!content) return '';
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 '';
@@ -1541,7 +1618,10 @@ export class SubagentWatcher extends EventEmitter {
/**
* Get truncated input for display (keeps primary param, truncates large content)
*/
private getTruncatedInput(_tool: string, input: Record<string, unknown>): Record<string, unknown> {
private getTruncatedInput(
_tool: string,
input: Record<string, unknown>
): Record<string, unknown> {
const truncated: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input)) {
if (typeof value === 'string' && value.length > INPUT_TRUNCATE_LENGTH) {
@@ -1608,7 +1688,10 @@ export class SubagentWatcher extends EventEmitter {
details = input.file_path as string;
} else if (name === 'Bash' && input.command) {
const cmd = input.command as string;
details = cmd.length > COMMAND_DISPLAY_LENGTH ? cmd.substring(0, COMMAND_DISPLAY_LENGTH) + '...' : cmd;
details =
cmd.length > COMMAND_DISPLAY_LENGTH
? cmd.substring(0, COMMAND_DISPLAY_LENGTH) + '...'
: cmd;
} else if (name === 'Glob' && input.pattern) {
details = input.pattern as string;
} else if (name === 'Grep' && input.pattern) {
+22 -8
View File
@@ -166,7 +166,11 @@ export class TaskQueue extends EventEmitter {
* @param visited - Set of already visited nodes (for DFS)
* @returns true if adding this dependency would create a cycle
*/
private wouldCreateCycle(taskId: string, depId: string, visited: Set<string> = new Set()): boolean {
private wouldCreateCycle(
taskId: string,
depId: string,
visited: Set<string> = new Set()
): boolean {
// Direct self-reference
if (depId === taskId) return true;
// Already visited this node in current path
@@ -196,7 +200,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 +214,23 @@ 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 +273,6 @@ export class TaskQueue extends EventEmitter {
this.tasks.clear();
return count;
}
}
// Singleton instance
+25 -14
View File
@@ -61,11 +61,7 @@ const LAUNCH_PATTERNS = [
* 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 +214,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 +315,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 +365,8 @@ 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 +613,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}`);
}
+17 -16
View File
@@ -86,13 +86,13 @@ export class TeamWatcher extends EventEmitter {
getTeamTasks(teamName: string): TeamTask[] {
const tasks = this.teamTasks.get(teamName);
if (!tasks) return [];
return tasks.filter(t => !t.metadata?._internal);
return tasks.filter((t) => !t.metadata?._internal);
}
/** Count active (non-completed) tasks for a team */
getActiveTaskCount(teamName: string): number {
const tasks = this.getTeamTasks(teamName);
return tasks.filter(t => t.status !== 'completed').length;
return tasks.filter((t) => t.status !== 'completed').length;
}
/** Get inbox messages for a team member (or all members) */
@@ -108,8 +108,8 @@ 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()
);
}
@@ -119,7 +119,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,19 +132,17 @@ 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'
);
}
/** Get count of active teammates for a session */
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 ==========
@@ -192,7 +190,8 @@ export class TeamWatcher extends EventEmitter {
if (await this.isLocked(join(this.teamsDir, entry, 'config.json'))) continue;
const config = await this.readJson<TeamConfig>(configPath);
if (!config || !config.name || !config.leadSessionId || !Array.isArray(config.members)) continue;
if (!config || !config.name || !config.leadSessionId || !Array.isArray(config.members))
continue;
const existing = this.teams.get(entry);
this.teams.set(entry, config);
@@ -257,7 +256,9 @@ 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 +286,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 +315,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 });
+11 -2
View File
@@ -44,7 +44,11 @@ const MINIMAL_FALLBACK = `# CLAUDE.md - Project Configuration
* @param templatePath - Optional path to a custom template file (from settings.json)
* @returns The generated CLAUDE.md content
*/
export function generateClaudeMd(caseName: string, description: string = '', templatePath?: string): string {
export function generateClaudeMd(
caseName: string,
description: string = '',
templatePath?: string
): string {
const date = new Date().toISOString().split('T')[0];
// 1. Try custom template from settings.json
@@ -71,7 +75,12 @@ export function generateClaudeMd(caseName: string, description: string = '', tem
return replacePlaceholders(MINIMAL_FALLBACK, caseName, description, date);
}
function replacePlaceholders(template: string, caseName: string, description: string, date: string): string {
function replacePlaceholders(
template: string,
caseName: string,
description: string,
date: string
): string {
return template
.replace(/\[PROJECT_NAME\]/g, caseName)
.replace(/\[PROJECT_DESCRIPTION\]/g, description || 'A new project')
+244 -139
View File
@@ -30,10 +30,25 @@ import { existsSync, readFileSync, mkdirSync } from 'node:fs';
import { writeFile, rename } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
import { ProcessStats, PersistedRespawnConfig, getErrorMessage, DEFAULT_NICE_CONFIG, type PaneInfo, type ClaudeMode, type SessionMode, type OpenCodeConfig } from './types.js';
import {
ProcessStats,
PersistedRespawnConfig,
getErrorMessage,
DEFAULT_NICE_CONFIG,
type PaneInfo,
type ClaudeMode,
type SessionMode,
type OpenCodeConfig,
} from './types.js';
import { wrapWithNice } from './utils/nice-wrapper.js';
import { SAFE_PATH_PATTERN } from './utils/regex-patterns.js';
import type { TerminalMultiplexer, MuxSession, MuxSessionWithStats, CreateSessionOptions, RespawnPaneOptions } from './mux-interface.js';
import type {
TerminalMultiplexer,
MuxSession,
MuxSessionWithStats,
CreateSessionOptions,
RespawnPaneOptions,
} from './mux-interface.js';
// Claude CLI PATH resolution — shared utility
import { findClaudeDir } from './utils/claude-cli-resolver.js';
@@ -103,11 +118,23 @@ function isValidMuxName(name: string): boolean {
* Prevents command injection via malformed paths.
*/
function isValidPath(path: string): boolean {
if (path.includes(';') || path.includes('&') || path.includes('|') ||
path.includes('$') || path.includes('`') || path.includes('(') ||
path.includes(')') || path.includes('{') || path.includes('}') ||
path.includes('<') || path.includes('>') || path.includes("'") ||
path.includes('"') || path.includes('\n') || path.includes('\r')) {
if (
path.includes(';') ||
path.includes('&') ||
path.includes('|') ||
path.includes('$') ||
path.includes('`') ||
path.includes('(') ||
path.includes(')') ||
path.includes('{') ||
path.includes('}') ||
path.includes('<') ||
path.includes('>') ||
path.includes("'") ||
path.includes('"') ||
path.includes('\n') ||
path.includes('\r')
) {
return false;
}
if (path.includes('..')) {
@@ -155,7 +182,9 @@ function buildOpenCodeCommand(config?: OpenCodeConfig): string {
// Continue existing session
if (config?.continueSession) {
const safeId = /^[a-zA-Z0-9_-]+$/.test(config.continueSession) ? config.continueSession : undefined;
const safeId = /^[a-zA-Z0-9_-]+$/.test(config.continueSession)
? config.continueSession
: undefined;
if (safeId) parts.push('--session', safeId);
if (safeId && config.forkSession) parts.push('--fork');
}
@@ -177,7 +206,8 @@ 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 +234,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 +257,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 +281,9 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch { /* Non-critical */ }
} catch {
/* Non-critical */
}
}
}
@@ -338,7 +374,17 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* In test mode: creates an in-memory session only (no real tmux session).
*/
async createSession(options: CreateSessionOptions): Promise<MuxSession> {
const { sessionId, workingDir, mode, name, niceConfig, model, claudeMode, allowedTools, openCodeConfig } = options;
const {
sessionId,
workingDir,
mode,
name,
niceConfig,
model,
claudeMode,
allowedTools,
openCodeConfig,
} = options;
const muxName = `codeman-${sessionId.slice(0, 8)}`;
if (!isValidMuxName(muxName)) {
@@ -370,13 +416,17 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (mode === 'claude') {
const claudeDir = findClaudeDir();
if (!claudeDir) {
throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash');
throw new Error(
'Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash'
);
}
pathExport = `export PATH="${claudeDir}:$PATH" && `;
} else if (mode === 'opencode') {
const openCodeDir = resolveOpenCodeDir();
if (!openCodeDir) {
throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
throw new Error(
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
);
}
pathExport = `export PATH="${openCodeDir}:$PATH" && `;
}
@@ -394,7 +444,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 +469,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 +494,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 +509,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 +541,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 +581,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 +607,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;
@@ -549,7 +623,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* preserving the session and its scrollback buffer.
*/
async respawnPane(options: RespawnPaneOptions): Promise<number | null> {
const { sessionId, workingDir, mode, niceConfig, model, claudeMode, allowedTools, openCodeConfig } = options;
const {
sessionId,
workingDir,
mode,
niceConfig,
model,
claudeMode,
allowedTools,
openCodeConfig,
} = options;
const session = this.sessions.get(sessionId);
if (!session) return null;
const muxName = session.muxName;
@@ -578,7 +661,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 +680,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 +717,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,16 +747,18 @@ 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(', ')}`);
console.warn(
`[TmuxManager] ${stillAlive.length} processes still alive after kill: ${stillAlive.join(', ')}`
);
}
return stillAlive.length === 0;
}
@@ -717,7 +811,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 +829,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');
}
@@ -765,7 +859,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Verify all processes are dead
const allDead = await this.verifyProcessesDead(allPids, 2000);
if (!allDead) {
console.error(`[TmuxManager] Warning: Some processes may still be alive for session ${session.muxName}`);
console.error(
`[TmuxManager] Warning: Some processes may still be alive for session ${session.muxName}`
);
}
this.lastPaneCount.delete(session.muxName);
@@ -829,14 +925,18 @@ 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();
if (!sessionName || (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-'))) continue;
if (
!sessionName ||
(!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-'))
)
continue;
// Check if this session is already known
let isKnown = false;
@@ -866,7 +966,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
};
this.sessions.set(sessionId, session);
discovered.push(sessionId);
console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`);
console.log(
`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`
);
}
}
}
@@ -890,26 +992,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 +1023,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 +1034,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 {
@@ -950,8 +1052,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);
}
}
@@ -1002,7 +1104,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 +1113,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 +1121,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 +1247,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 +1258,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 +1276,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 +1317,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 +1341,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,16 +1385,19 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
).trim();
return output.split('\n').map(line => {
const [paneId, indexStr, pidStr, widthStr, heightStr] = line.split(':');
return {
paneId,
paneIndex: parseInt(indexStr, 10),
panePid: parseInt(pidStr, 10),
width: parseInt(widthStr, 10),
height: parseInt(heightStr, 10),
};
}).filter(p => !Number.isNaN(p.paneIndex));
return output
.split('\n')
.map((line) => {
const [paneId, indexStr, pidStr, widthStr, heightStr] = line.split(':');
return {
paneId,
paneIndex: parseInt(indexStr, 10),
panePid: parseInt(pidStr, 10),
width: parseInt(widthStr, 10),
height: parseInt(heightStr, 10),
};
})
.filter((p) => !Number.isNaN(p.paneIndex));
} catch {
return [];
}
@@ -1323,24 +1428,24 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
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;
@@ -1370,10 +1475,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
: `${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;
@@ -1434,10 +1539,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
: `${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);
+7 -6
View File
@@ -410,12 +410,13 @@ export class TranscriptWatcher extends EventEmitter {
if (entry.type !== 'assistant' || !entry.message?.content) return;
const content = entry.message.content;
const textToCheck = typeof content === 'string'
? content
: content
.filter((b): b is { type: 'text'; text: string } => b.type === 'text' && !!b.text)
.map(b => b.text)
.join(' ');
const textToCheck =
typeof content === 'string'
? content
: content
.filter((b): b is { type: 'text'; text: string } => b.type === 'text' && !!b.text)
.map((b) => b.text)
.join(' ');
// Also check for tool_use with ExitPlanMode or AskUserQuestion
if (Array.isArray(content)) {
+8 -2
View File
@@ -112,7 +112,10 @@ export class TunnelManager extends EventEmitter {
const binary = this.resolveCloudflared();
if (!binary) {
this.emit('error', 'cloudflared not found. Install from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/');
this.emit(
'error',
'cloudflared not found. Install from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/'
);
return;
}
@@ -130,7 +133,10 @@ export class TunnelManager extends EventEmitter {
detached: false,
});
} catch (err) {
this.emit('error', `Failed to spawn cloudflared: ${err instanceof Error ? err.message : String(err)}`);
this.emit(
'error',
`Failed to spawn cloudflared: ${err instanceof Error ? err.message : String(err)}`
);
return;
}
+28 -22
View File
@@ -80,16 +80,16 @@ export type TddPhase = 'setup' | 'test' | 'impl' | 'verify' | 'review';
/** Types of session lifecycle events recorded to the audit log */
export type LifecycleEventType =
| 'created' // Session object created
| 'started' // PTY process launched (interactive/shell/prompt)
| 'exit' // PTY process exited (with exit code)
| 'deleted' // cleanupSession() called — session removed
| 'detached' // Server shutdown — PTY left alive in tmux for recovery
| 'recovered' // Session restored from tmux on server restart
| 'stale_cleaned' // Removed from state.json by cleanupStaleSessions()
| 'mux_died' // tmux session died (detected by reconciliation)
| 'server_started' // Server started (marker for restart detection)
| 'server_stopped'; // Server shutting down
| 'created' // Session object created
| 'started' // PTY process launched (interactive/shell/prompt)
| 'exit' // PTY process exited (with exit code)
| 'deleted' // cleanupSession() called — session removed
| 'detached' // Server shutdown — PTY left alive in tmux for recovery
| 'recovered' // Session restored from tmux on server restart
| 'stale_cleaned' // Removed from state.json by cleanupStaleSessions()
| 'mux_died' // tmux session died (detected by reconciliation)
| 'server_started' // Server started (marker for restart detection)
| 'server_stopped'; // Server shutting down
/** A single entry in the session lifecycle audit log */
export interface LifecycleEntry {
@@ -472,11 +472,11 @@ export interface RespawnConfig {
* Outcome of a respawn cycle
*/
export type CycleOutcome =
| 'success' // Cycle completed normally
| 'stuck_recovery' // Stuck-state recovery triggered
| 'blocked' // Blocked by circuit breaker or exit signal
| 'error' // Error during cycle
| 'cancelled'; // Cancelled (e.g., controller stopped)
| 'success' // Cycle completed normally
| 'stuck_recovery' // Stuck-state recovery triggered
| 'blocked' // Blocked by circuit breaker or exit signal
| 'error' // Error during cycle
| 'cancelled'; // Cancelled (e.g., controller stopped)
/**
* Metrics for a single respawn cycle.
@@ -687,7 +687,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 ==========
@@ -842,12 +848,12 @@ export const DEFAULT_CONFIG: AppConfig = {
maxConcurrentSessions: 5,
stateFilePath: '',
respawn: {
idleTimeoutMs: 5000, // 5 seconds of no activity after prompt
idleTimeoutMs: 5000, // 5 seconds of no activity after prompt
updatePrompt: 'update all the docs and CLAUDE.md',
interStepDelayMs: 1000, // 1 second between steps
enabled: false, // disabled by default
sendClear: true, // send /clear after update prompt
sendInit: true, // send /init after /clear
interStepDelayMs: 1000, // 1 second between steps
enabled: false, // disabled by default
sendClear: true, // send /clear after update prompt
sendInit: true, // send /init after /clear
},
lastUsedCase: null,
ralphEnabled: false,
@@ -1117,7 +1123,7 @@ export function createInitialCircuitBreakerStatus(): CircuitBreakerStatus {
*/
export function createInitialRalphTrackerState(): RalphTrackerState {
return {
enabled: false, // Disabled by default, auto-enables when Ralph patterns detected
enabled: false, // Disabled by default, auto-enables when Ralph patterns detected
active: false,
completionPhrase: null,
startedAt: null,
+1 -1
View File
@@ -149,7 +149,7 @@ export class BufferAccumulator {
* @returns True if buffer ends with the suffix
*/
endsWith(suffix: string): boolean {
if (!suffix) return true; // All strings end with empty string
if (!suffix) return true; // All strings end with empty string
if (suffix.length > this.totalLength) return false;
return this.tail(suffix.length) === suffix;
}
+1 -1
View File
@@ -57,7 +57,7 @@ export function findClaudeDir(): string | null {
}
}
_claudeDir = ''; // mark as searched, not found
_claudeDir = ''; // mark as searched, not found
return null;
}
+36 -41
View File
@@ -115,11 +115,7 @@ export class CleanupManager implements Disposable {
* @param options - Optional configuration
* @returns Timer ID for manual clearing if needed
*/
setTimeout(
callback: () => void,
delay: number,
options?: TimerOptions
): string {
setTimeout(callback: () => void, delay: number, options?: TimerOptions): string {
const id = uuidv4();
const timeoutId = setTimeout(() => {
// Remove registration when timer fires naturally
@@ -148,11 +144,7 @@ export class CleanupManager implements Disposable {
* @param options - Optional configuration
* @returns Interval ID for manual clearing if needed
*/
setInterval(
callback: () => void,
delay: number,
options?: TimerOptions
): string {
setInterval(callback: () => void, delay: number, options?: TimerOptions): string {
const id = uuidv4();
const intervalId = setInterval(() => {
// Don't execute if stopped
@@ -179,11 +171,7 @@ export class CleanupManager implements Disposable {
* @param description - Human-readable description
* @returns Registration ID for manual removal if needed
*/
registerCleanup(
type: CleanupResourceType,
cleanup: () => void,
description: string
): string {
registerCleanup(type: CleanupResourceType, cleanup: () => void, description: string): string {
const id = uuidv4();
this.register({
id,
@@ -202,10 +190,7 @@ export class CleanupManager implements Disposable {
* @param description - Human-readable description
* @returns Registration ID
*/
registerWatcher(
watcher: { close: () => void },
description: string
): string {
registerWatcher(watcher: { close: () => void }, description: string): string {
return this.registerCleanup('watcher', () => watcher.close(), description);
}
@@ -218,19 +203,23 @@ export class CleanupManager implements Disposable {
* @param description - Human-readable description
* @returns Registration ID
*/
registerListener<T extends { removeListener?: (event: string, listener: () => void) => void; off?: (event: string, listener: () => void) => void }>(
emitter: T,
event: string,
listener: () => void,
description: string
): string {
return this.registerCleanup('listener', () => {
if (emitter.removeListener) {
emitter.removeListener(event, listener);
} else if (emitter.off) {
emitter.off(event, listener);
}
}, description);
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
);
}
/**
@@ -244,13 +233,17 @@ export class CleanupManager implements Disposable {
stream: { destroy?: () => void; close?: () => void },
description: string
): string {
return this.registerCleanup('stream', () => {
if (stream.destroy) {
stream.destroy();
} else if (stream.close) {
stream.close();
}
}, description);
return this.registerCleanup(
'stream',
() => {
if (stream.destroy) {
stream.destroy();
} else if (stream.close) {
stream.close();
}
},
description
);
}
/**
@@ -297,8 +290,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(', ')
);
}
}
+5 -1
View File
@@ -33,4 +33,8 @@ export {
export { assertNever } from './type-safety.js';
export { wrapWithNice } from './nice-wrapper.js';
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
export { resolveOpenCodeDir, isOpenCodeAvailable, getOpenCodeAugmentedPath } from './opencode-cli-resolver.js';
export {
resolveOpenCodeDir,
isOpenCodeAvailable,
getOpenCodeAugmentedPath,
} from './opencode-cli-resolver.js';
+12 -9
View File
@@ -17,13 +17,13 @@ const EXEC_TIMEOUT_MS = 5000;
/** Common directories where the OpenCode CLI binary may be installed */
const OPENCODE_SEARCH_DIRS = [
join(homedir(), '.opencode', 'bin'), // Default install location
join(homedir(), '.local', 'bin'), // Alternative install location
'/usr/local/bin', // Homebrew / system
join(homedir(), 'go', 'bin'), // Go install
join(homedir(), '.bun', 'bin'), // Bun global
join(homedir(), '.npm-global', 'bin'), // npm global
join(homedir(), 'bin'), // User bin
join(homedir(), '.opencode', 'bin'), // Default install location
join(homedir(), '.local', 'bin'), // Alternative install location
'/usr/local/bin', // Homebrew / system
join(homedir(), 'go', 'bin'), // Go install
join(homedir(), '.bun', 'bin'), // Bun global
join(homedir(), '.npm-global', 'bin'), // npm global
join(homedir(), 'bin'), // User bin
];
/** Cached directory containing the opencode binary (empty string = searched but not found) */
@@ -41,7 +41,10 @@ export function resolveOpenCodeDir(): string | null {
// Try `which` first (respects current PATH)
try {
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;
@@ -58,7 +61,7 @@ export function resolveOpenCodeDir(): string | null {
}
}
_openCodeDir = ''; // mark as searched, not found
_openCodeDir = ''; // mark as searched, not found
return null;
}
+2 -2
View File
@@ -17,7 +17,8 @@
* Use this when you need complete ANSI stripping including OSC sequences.
* Note: Has global flag - reset lastIndex before exec() if reusing.
*/
export const ANSI_ESCAPE_PATTERN_FULL = /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g;
export const ANSI_ESCAPE_PATTERN_FULL =
/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g;
/**
* Simple ANSI CSI-only pattern for basic escape code stripping.
@@ -75,4 +76,3 @@ export function stripAnsi(text: string): string {
export const SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧]/;
export const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_\/\-. ~]+$/;
+4 -8
View File
@@ -55,8 +55,8 @@ export function levenshteinDistance(a: string, b: string): number {
for (let i = 1; i <= m; i++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
curr[i] = Math.min(
prev[i] + 1, // deletion
curr[i - 1] + 1, // insertion
prev[i] + 1, // deletion
curr[i - 1] + 1, // insertion
prev[i - 1] + cost // substitution
);
}
@@ -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);
+198 -113
View File
@@ -15,11 +15,23 @@ import { SAFE_PATH_PATTERN } from '../utils/regex-patterns.js';
/** Validate a path string: no shell metacharacters, no traversal, must be absolute */
export function isValidWorkingDir(p: string): boolean {
if (!p || !p.startsWith('/')) return false;
if (p.includes(';') || p.includes('&') || p.includes('|') ||
p.includes('$') || p.includes('`') || p.includes('(') ||
p.includes(')') || p.includes('{') || p.includes('}') ||
p.includes('<') || p.includes('>') || p.includes("'") ||
p.includes('"') || p.includes('\n') || p.includes('\r')) {
if (
p.includes(';') ||
p.includes('&') ||
p.includes('|') ||
p.includes('$') ||
p.includes('`') ||
p.includes('(') ||
p.includes(')') ||
p.includes('{') ||
p.includes('}') ||
p.includes('<') ||
p.includes('>') ||
p.includes("'") ||
p.includes('"') ||
p.includes('\n') ||
p.includes('\r')
) {
return false;
}
if (p.includes('..')) return false;
@@ -38,25 +50,35 @@ const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_'];
/** Env var keys that are always blocked (security-sensitive) */
const BLOCKED_ENV_KEYS = new Set([
'PATH', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'NODE_OPTIONS',
'CODEMAN_MUX_NAME', 'CODEMAN_TMUX',
'OPENCODE_SERVER_PASSWORD', // Security-sensitive: server auth password
'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(
(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.' },
);
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.',
}
);
// ========== 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(),
autoAllowTools: z.boolean().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();
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(),
forkSession: z.boolean().optional(),
configContent: z
.string()
.max(10000)
.refine(
(val) => {
try {
JSON.parse(val);
return true;
} catch {
return false;
}
},
{ message: 'configContent must be valid JSON' }
)
.optional(),
})
.optional();
export const CreateSessionSchema = z.object({
workingDir: safePathSchema.optional(),
@@ -108,7 +151,12 @@ 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 +167,13 @@ 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,
});
@@ -131,7 +185,14 @@ export const QuickStartSchema = z.object({
* Receives Claude Code hook events.
*/
export const HookEventSchema = z.object({
event: z.enum(['permission_prompt', 'elicitation_dialog', 'idle_prompt', 'stop', 'teammate_idle', 'task_completed']),
event: z.enum([
'permission_prompt',
'elicitation_dialog',
'idle_prompt',
'stop',
'teammate_idle',
'task_completed',
]),
sessionId: z.string().min(1),
data: z.record(z.string(), z.unknown()).nullable().optional(),
});
@@ -175,86 +236,100 @@ export const RespawnConfigSchema = z.object({
* Schema for PUT /api/config
* Updates application configuration with whitelist of allowed fields.
*/
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();
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();
/**
* Schema for PUT /api/settings
* Explicit allowlist of known settings fields — prevents arbitrary key persistence.
*/
const NotificationEventSchema = z.object({
enabled: z.boolean().optional(),
browser: z.boolean().optional(),
audio: z.boolean().optional(),
push: z.boolean().optional(),
}).optional();
const NotificationEventSchema = z
.object({
enabled: z.boolean().optional(),
browser: z.boolean().optional(),
audio: z.boolean().optional(),
push: z.boolean().optional(),
})
.optional();
export const SettingsUpdateSchema = z.object({
// Paths
defaultClaudeMdPath: z.string().max(500).optional(),
defaultWorkingDir: z.string().max(500).optional(),
lastUsedCase: z.string().max(200).optional(),
// Feature toggles
ralphTrackerEnabled: z.boolean().optional(),
subagentTrackingEnabled: z.boolean().optional(),
subagentActiveTabOnly: z.boolean().optional(),
imageWatcherEnabled: z.boolean().optional(),
tunnelEnabled: z.boolean().optional(),
tabTwoRows: z.boolean().optional(),
agentTeamsEnabled: z.boolean().optional(),
// UI visibility
showFontControls: z.boolean().optional(),
showSystemStats: z.boolean().optional(),
showTokenCount: z.boolean().optional(),
showCost: z.boolean().optional(),
showLifecycleLog: z.boolean().optional(),
showMonitor: z.boolean().optional(),
showProjectInsights: z.boolean().optional(),
showFileBrowser: z.boolean().optional(),
showSubagents: z.boolean().optional(),
// Claude CLI settings
claudeMode: z.string().max(50).optional(),
allowedTools: z.string().max(2000).optional(),
// CPU priority
nice: z.object({
enabled: z.boolean().optional(),
niceValue: z.number().int().min(-20).max(19).optional(),
}).optional(),
// Notification preferences (cross-device sync)
notificationPreferences: z.object({
enabled: z.boolean().optional(),
browserNotifications: z.boolean().optional(),
audioAlerts: z.boolean().optional(),
stuckThresholdMs: z.number().optional(),
muteCritical: z.boolean().optional(),
muteWarning: z.boolean().optional(),
muteInfo: z.boolean().optional(),
eventTypes: z.object({
permission_prompt: NotificationEventSchema,
elicitation_dialog: NotificationEventSchema,
idle_prompt: NotificationEventSchema,
stop: NotificationEventSchema,
session_error: NotificationEventSchema,
respawn_cycle: NotificationEventSchema,
token_milestone: NotificationEventSchema,
ralph_complete: NotificationEventSchema,
subagent_spawn: NotificationEventSchema,
subagent_complete: NotificationEventSchema,
}).optional(),
_version: z.number().optional(),
}).optional(),
// Voice settings (cross-device sync)
voiceSettings: z.object({
apiKey: z.string().max(200).optional(),
language: z.string().max(20).optional(),
keyterms: z.string().max(500).optional(),
insertMode: z.string().max(20).optional(),
}).optional(),
}).strict();
export const SettingsUpdateSchema = z
.object({
// Paths
defaultClaudeMdPath: z.string().max(500).optional(),
defaultWorkingDir: z.string().max(500).optional(),
lastUsedCase: z.string().max(200).optional(),
// Feature toggles
ralphTrackerEnabled: z.boolean().optional(),
subagentTrackingEnabled: z.boolean().optional(),
subagentActiveTabOnly: z.boolean().optional(),
imageWatcherEnabled: z.boolean().optional(),
tunnelEnabled: z.boolean().optional(),
tabTwoRows: z.boolean().optional(),
agentTeamsEnabled: z.boolean().optional(),
// UI visibility
showFontControls: z.boolean().optional(),
showSystemStats: z.boolean().optional(),
showTokenCount: z.boolean().optional(),
showCost: z.boolean().optional(),
showLifecycleLog: z.boolean().optional(),
showMonitor: z.boolean().optional(),
showProjectInsights: z.boolean().optional(),
showFileBrowser: z.boolean().optional(),
showSubagents: z.boolean().optional(),
// Claude CLI settings
claudeMode: z.string().max(50).optional(),
allowedTools: z.string().max(2000).optional(),
// CPU priority
nice: z
.object({
enabled: z.boolean().optional(),
niceValue: z.number().int().min(-20).max(19).optional(),
})
.optional(),
// Notification preferences (cross-device sync)
notificationPreferences: z
.object({
enabled: z.boolean().optional(),
browserNotifications: z.boolean().optional(),
audioAlerts: z.boolean().optional(),
stuckThresholdMs: z.number().optional(),
muteCritical: z.boolean().optional(),
muteWarning: z.boolean().optional(),
muteInfo: z.boolean().optional(),
eventTypes: z
.object({
permission_prompt: NotificationEventSchema,
elicitation_dialog: NotificationEventSchema,
idle_prompt: NotificationEventSchema,
stop: NotificationEventSchema,
session_error: NotificationEventSchema,
respawn_cycle: NotificationEventSchema,
token_milestone: NotificationEventSchema,
ralph_complete: NotificationEventSchema,
subagent_spawn: NotificationEventSchema,
subagent_complete: NotificationEventSchema,
})
.optional(),
_version: z.number().optional(),
})
.optional(),
// Voice settings (cross-device sync)
voiceSettings: z
.object({
apiKey: z.string().max(200).optional(),
language: z.string().max(20).optional(),
keyterms: z.string().max(500).optional(),
insertMode: z.string().max(20).optional(),
})
.optional(),
})
.strict();
/**
* Schema for POST /api/sessions/:id/input with length limit
@@ -381,10 +456,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({
minimized: z.record(z.string(), z.boolean()).optional(),
open: z.array(z.string()).optional(),
}).passthrough();
export const SubagentWindowStatesSchema = z
.object({
minimized: z.record(z.string(), z.boolean()).optional(),
open: z.array(z.string()).optional(),
})
.passthrough();
/** PUT /api/subagent-parents */
export const SubagentParentMapSchema = z.record(z.string(), z.string());
@@ -423,14 +500,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({
content: z.string(),
priority: z.string().optional(),
enabled: z.boolean().default(true),
})).optional(),
planItems: z
.array(
z.object({
content: z.string(),
priority: z.string().optional(),
enabled: z.boolean().default(true),
})
)
.optional(),
});
+1104 -431
View File
File diff suppressed because it is too large Load Diff