fix layout

This commit is contained in:
Noah Waldner
2026-02-27 11:46:40 +01:00
parent 25293a0349
commit 406c18cefe
33 changed files with 452 additions and 1308 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"singleQuote": true,
"semi": true,
"tabWidth": 2,
"printWidth": 100,
"printWidth": 120,
"trailingComma": "es5",
"endOfLine": "lf"
}
+13 -37
View File
@@ -205,9 +205,7 @@ export abstract class AiCheckerBase<
super();
this.sessionId = sessionId;
// Filter out undefined values to prevent overwriting defaults
const filteredConfig = Object.fromEntries(
Object.entries(config).filter(([, v]) => v !== undefined)
) as Partial<C>;
const filteredConfig = Object.fromEntries(Object.entries(config).filter(([, v]) => v !== undefined)) as Partial<C>;
this.config = { ...defaultConfig, ...filteredConfig };
}
@@ -283,15 +281,11 @@ 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');
}
@@ -346,9 +340,7 @@ export abstract class AiCheckerBase<
/** Update configuration at runtime */
updateConfig(config: Partial<C>): void {
// Filter out undefined values to prevent overwriting existing config
const filteredConfig = Object.fromEntries(
Object.entries(config).filter(([, v]) => v !== undefined)
) as Partial<C>;
const filteredConfig = Object.fromEntries(Object.entries(config).filter(([, v]) => v !== undefined)) as Partial<C>;
this.config = { ...this.config, ...filteredConfig };
if (config.enabled === false) {
this.disable('Disabled by config');
@@ -374,9 +366,7 @@ 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;
stripped.length > this.config.maxContextChars ? stripped.slice(-this.config.maxContextChars) : stripped;
// Build the prompt
const prompt = this.buildPrompt(trimmed);
@@ -385,10 +375,7 @@ 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
@@ -419,14 +406,10 @@ 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(
@@ -468,9 +451,7 @@ 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);
});
@@ -487,10 +468,7 @@ 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);
@@ -560,9 +538,7 @@ 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);
}
}
+2 -11
View File
@@ -123,12 +123,7 @@ Remember: When uncertain, answer WORKING.`;
* Manages AI-powered idle detection by spawning a fresh Claude CLI session
* to analyze terminal output and provide a definitive IDLE/WORKING verdict.
*/
export class AiIdleChecker extends AiCheckerBase<
AiCheckVerdict,
AiIdleCheckConfig,
AiCheckResult,
AiCheckState
> {
export class AiIdleChecker extends AiCheckerBase<AiCheckVerdict, AiIdleCheckConfig, AiCheckResult, AiCheckState> {
protected readonly muxNamePrefix = 'codeman-aicheck-';
protected readonly doneMarker = '__AICHECK_DONE__';
protected readonly tempFilePrefix = 'codeman-aicheck';
@@ -170,11 +165,7 @@ 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 };
}
}
+2 -8
View File
@@ -105,9 +105,7 @@ 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;
@@ -134,11 +132,7 @@ 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 };
}
}
+5 -13
View File
@@ -85,15 +85,13 @@ const INVALID_PATH_PATTERN = /^[/~]-|\/dev\/null$/;
* 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 ==========
@@ -421,9 +419,7 @@ 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);
trimPoint > 0 ? this._lineBuffer.slice(trimPoint + 1) : this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
}
// Process complete lines
@@ -449,9 +445,7 @@ 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);
trimPoint > 0 ? this._lineBuffer.slice(trimPoint + 1) : this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
}
const lines = this._lineBuffer.split('\n');
@@ -505,9 +499,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
// Enforce max tools limit
if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
// Remove oldest tool
const oldest = Array.from(this._activeTools.entries()).sort(
(a, b) => a[1].startedAt - b[1].startedAt
)[0];
const oldest = Array.from(this._activeTools.entries()).sort((a, b) => a[1].startedAt - b[1].startedAt)[0];
if (oldest) {
this._activeTools.delete(oldest[0]);
}
+12 -40
View File
@@ -21,10 +21,7 @@ const pkg = require('../package.json') as { version: string };
const program = new Command();
program
.name('codeman')
.description('Claude Code session manager with autonomous Ralph Loop')
.version(pkg.version);
program.name('codeman').description('Claude Code session manager with autonomous Ralph Loop').version(pkg.version);
// ============ Session Commands ============
@@ -95,9 +92,7 @@ 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}`);
}
}
@@ -115,9 +110,7 @@ sessionCmd
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('');
@@ -198,9 +191,7 @@ 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();
@@ -278,10 +269,7 @@ taskCmd
// ============ Ralph Loop Commands ============
const ralphCmd = program
.command('ralph')
.alias('r')
.description('Control the Ralph autonomous loop');
const ralphCmd = program.command('ralph').alias('r').description('Control the Ralph autonomous loop');
ralphCmd
.command('start')
@@ -303,9 +291,7 @@ 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) => {
@@ -359,12 +345,7 @@ ralphCmd
});
function printStats(stats: ReturnType<ReturnType<typeof getRalphLoop>['getStats']>) {
const statusColor =
stats.status === 'running'
? chalk.green
: stats.status === 'paused'
? chalk.yellow
: chalk.gray;
const statusColor = stats.status === 'running' ? chalk.green : stats.status === 'paused' ? chalk.yellow : chalk.gray;
console.log(chalk.bold('\nRalph Loop Status:'));
console.log(` Status: ${statusColor(stats.status)}`);
@@ -407,8 +388,7 @@ 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;
@@ -431,8 +411,7 @@ program
console.log(` Completed: ${taskCounts.completed}`);
console.log(` Failed: ${taskCounts.failed}`);
const statusColor =
loopStatus === 'running' ? chalk.green : loopStatus === 'paused' ? chalk.yellow : chalk.gray;
const statusColor = loopStatus === 'running' ? chalk.green : loopStatus === 'paused' ? chalk.yellow : chalk.gray;
console.log(chalk.bold('\nRalph Loop:'));
console.log(` Status: ${statusColor(loopStatus)}`);
console.log('');
@@ -505,27 +484,20 @@ 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'));
+3 -14
View File
@@ -141,15 +141,7 @@ 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;
@@ -178,8 +170,7 @@ export class FileStreamManager extends EventEmitter {
};
}
} catch (err) {
const errorCode =
err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : 'UNKNOWN';
const errorCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : 'UNKNOWN';
console.warn(
`[FileStreamManager] Failed to stat file "${absolutePath}" (${errorCode}):`,
err instanceof Error ? err.message : String(err)
@@ -391,9 +382,7 @@ export class FileStreamManager extends EventEmitter {
}
// Resolve to absolute path
let absolutePath = isAbsolute(expandedPath)
? resolve(expandedPath)
: resolve(workingDir, expandedPath);
let absolutePath = isAbsolute(expandedPath) ? resolve(expandedPath) : resolve(workingDir, expandedPath);
// Resolve symlinks to prevent symlink attacks — validate the real target,
// not the symlink itself. Fall back to resolved path if file doesn't exist yet.
+1 -4
View File
@@ -71,10 +71,7 @@ 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 });
+4 -20
View File
@@ -111,11 +111,7 @@ 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();
@@ -194,11 +190,7 @@ 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);
}
}
@@ -213,11 +205,7 @@ 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);
}
@@ -322,11 +310,7 @@ 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);
}
}
}
+1 -3
View File
@@ -28,9 +28,7 @@ function trackError(): void {
}, 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);
}
}
+7 -23
View File
@@ -172,18 +172,11 @@ export class PlanOrchestrator {
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 {
@@ -433,10 +426,7 @@ 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(() => {
@@ -517,18 +507,12 @@ 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,
typeof data.enrichedTaskDescription === 'string' ? data.enrichedTaskDescription : taskDescription,
durationMs,
};
+1 -4
View File
@@ -95,10 +95,7 @@ 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 -5
View File
@@ -406,11 +406,7 @@ export class RalphLoop extends EventEmitter {
// 2. Min duration not reached
// 3. We have idle sessions
const counts = this.taskQueue.getCount();
return (
counts.pending === 0 &&
!this.isMinDurationReached() &&
this.sessionManager.getIdleSessions().length > 0
);
return counts.pending === 0 && !this.isMinDurationReached() && this.sessionManager.getIdleSessions().length > 0;
}
private async generateFollowUpTasks(): Promise<void> {
+25 -84
View File
@@ -34,12 +34,7 @@ import {
PlanTaskStatus,
TddPhase,
} from './types.js';
import {
ANSI_ESCAPE_PATTERN_SIMPLE,
fuzzyPhraseMatch,
todoContentHash,
stringSimilarity,
} from './utils/index.js';
import { ANSI_ESCAPE_PATTERN_SIMPLE, fuzzyPhraseMatch, todoContentHash, stringSimilarity } from './utils/index.js';
import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js';
import { MAX_TODOS_PER_SESSION } from './config/map-limits.js';
@@ -246,8 +241,7 @@ 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.
@@ -297,15 +291,13 @@ 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
@@ -318,8 +310,7 @@ 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 ----------
@@ -394,8 +385,7 @@ 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
@@ -1704,8 +1694,7 @@ export class RalphTracker extends EventEmitter {
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');
!line.toLowerCase().includes('completion phrase') && !line.toLowerCase().includes('output exactly');
if (isNotInPromptContext && isNotExplanation) {
this.handleBareCompletionPhrase(expectedPhrase);
@@ -1791,10 +1780,7 @@ 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);
}
}
@@ -2085,10 +2071,8 @@ 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
@@ -2196,11 +2180,7 @@ export class RalphTracker extends EventEmitter {
const taskNum = parseInt(match[1], 10);
const statusStr = match[2].trim();
const status: RalphTodoStatus =
statusStr === 'completed'
? 'completed'
: statusStr === 'in progress'
? 'in_progress'
: 'pending';
statusStr === 'completed' ? 'completed' : statusStr === 'in progress' ? 'in_progress' : 'pending';
const content = this._taskNumberToContent.get(taskNum);
if (content) {
this.upsertTodo(content, status);
@@ -2606,14 +2586,7 @@ export class RalphTracker extends EventEmitter {
];
// Moderate: Bugs, features, enhancements
const moderatePatterns = [
/\bbug\b/,
/\bfeature\b/,
/\benhance(?:ment)?\b/,
/\bimplement\b/,
/\badd\b/,
/\bfix\b/,
];
const moderatePatterns = [/\bbug\b/, /\bfeature\b/, /\benhance(?:ment)?\b/, /\bimplement\b/, /\badd\b/, /\bfix\b/];
for (const pattern of complexPatterns) {
if (pattern.test(lower)) return 'complex';
@@ -2895,11 +2868,7 @@ 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;
}
@@ -3103,9 +3072,7 @@ 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;
}
@@ -3131,9 +3098,7 @@ 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;
}
@@ -3145,9 +3110,7 @@ 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;
}
@@ -3278,11 +3241,7 @@ export class RalphTracker extends EventEmitter {
* @param status - Overall status from RALPH_STATUS
* @fires circuitBreakerUpdate - If state changes
*/
private updateCircuitBreaker(
hasProgress: boolean,
testsStatus: RalphTestsStatus,
status: RalphStatusValue
): void {
private updateCircuitBreaker(hasProgress: boolean, testsStatus: RalphTestsStatus, status: RalphStatusValue): void {
const prevState = this._circuitBreaker.state;
if (hasProgress) {
@@ -3323,10 +3282,7 @@ 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';
@@ -3568,9 +3524,7 @@ export class RalphTracker extends EventEmitter {
}
// Parse priority from content if not in a priority section
const parsedPriority = inCompletedSection
? null
: currentPriority || this.parsePriority(content);
const parsedPriority = inCompletedSection ? null : currentPriority || this.parsePriority(content);
const id = this.generateTodoId(content);
newTodos.push({
@@ -3724,10 +3678,7 @@ export class RalphTracker extends EventEmitter {
*/
private _checkForCheckpoint(): void {
const currentIteration = this._loopState.cycleCount;
if (
this._checkpointIterations.includes(currentIteration) &&
currentIteration > this._lastCheckpointIteration
) {
if (this._checkpointIterations.includes(currentIteration) && currentIteration > this._lastCheckpointIteration) {
this._lastCheckpointIteration = currentIteration;
const checkpoint = this.generateCheckpointReview();
this.emit('planCheckpoint', checkpoint);
@@ -3763,23 +3714,16 @@ 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) {
@@ -3932,10 +3876,7 @@ export class RalphTracker extends EventEmitter {
*/
isCheckpointDue(): boolean {
const currentIteration = this._loopState.cycleCount;
return (
this._checkpointIterations.includes(currentIteration) &&
currentIteration > this._lastCheckpointIteration
);
return this._checkpointIterations.includes(currentIteration) && currentIteration > this._lastCheckpointIteration;
}
/**
+43 -143
View File
@@ -42,10 +42,7 @@ import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js';
import type { TeamWatcher } from './team-watcher.js';
import { BufferAccumulator } from './utils/buffer-accumulator.js';
import { ANSI_ESCAPE_PATTERN_SIMPLE, TOKEN_PATTERN, assertNever } from './utils/index.js';
import {
MAX_RESPAWN_BUFFER_SIZE,
TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE,
} from './config/buffer-limits.js';
import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js';
import type {
RespawnCycleMetrics,
RespawnAggregateMetrics,
@@ -539,8 +536,7 @@ 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.',
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
@@ -734,10 +730,8 @@ export class RespawnController extends EventEmitter {
// ========== Timer Tracking for UI Countdown Display ==========
/** Active timers being tracked for UI display */
private activeTimers: Map<
string,
{ name: string; startedAt: number; durationMs: number; endsAt: number }
> = new Map();
private activeTimers: Map<string, { name: string; startedAt: number; durationMs: number; endsAt: number }> =
new Map();
/** Recent action log entries (for UI display, max 20) */
private recentActions: ActionLogEntry[] = [];
@@ -952,14 +946,12 @@ 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) */
@@ -1041,8 +1033,7 @@ 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
@@ -1501,10 +1492,7 @@ 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;
}
@@ -1559,9 +1547,7 @@ 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 {
@@ -1880,10 +1866,7 @@ 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.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})`
);
@@ -1902,13 +1885,8 @@ 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);
}
}
@@ -2013,21 +1991,13 @@ export class RespawnController extends EventEmitter {
* Start a tracked timer with UI countdown support.
* Emits timerStarted event and tracks the timer for UI display.
*/
private startTrackedTimer(
name: string,
durationMs: number,
callback: () => void,
reason?: string
): NodeJS.Timeout {
private startTrackedTimer(name: string, durationMs: number, callback: () => void, reason?: string): NodeJS.Timeout {
const now = Date.now();
const endsAt = now + durationMs;
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);
@@ -2100,9 +2070,7 @@ 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)
@@ -2149,10 +2117,7 @@ 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)');
@@ -2205,10 +2170,7 @@ 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');
}
}
@@ -2242,9 +2204,7 @@ 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;
}
@@ -2302,11 +2262,7 @@ export class RespawnController extends EventEmitter {
if (result.verdict === 'IDLE') {
// Cancel any pending confirmation timers - AI has spoken
this.cancelTrackedTimer(
'completion-confirm',
this.completionConfirmTimer,
'AI verdict: IDLE'
);
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: IDLE');
this.completionConfirmTimer = null;
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'AI verdict: IDLE');
this.preFilterTimer = null;
@@ -2316,19 +2272,13 @@ export class RespawnController extends EventEmitter {
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.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`
);
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();
@@ -2521,10 +2471,7 @@ export class RespawnController extends EventEmitter {
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.logAction('plan-check', `Verdict: PLAN_MODE but state is ${this._state}, not sending Enter`);
return;
}
this.emit('planCheckCompleted', result);
@@ -2554,9 +2501,7 @@ 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') {
@@ -2602,11 +2547,7 @@ 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;
}
@@ -2624,11 +2565,7 @@ 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
@@ -2649,11 +2586,7 @@ 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;
}
@@ -2671,11 +2604,7 @@ 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;
@@ -2721,10 +2650,7 @@ 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`
@@ -2835,10 +2761,7 @@ 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) {
@@ -2878,9 +2801,7 @@ 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();
@@ -2936,10 +2857,7 @@ 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.logAction('ralph', `Exit signal detected: ${statusBlock.recommendation || 'Task complete'}`);
this.emit('respawnBlocked', {
reason: 'exit_signal',
details: statusBlock.recommendation || 'Task complete',
@@ -2952,10 +2870,7 @@ 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.logAction('ralph', `Claude reported BLOCKED: ${statusBlock.recommendation || 'Needs human intervention'}`);
this.emit('respawnBlocked', {
reason: 'status_blocked',
details: statusBlock.recommendation || 'Needs human intervention',
@@ -2979,11 +2894,7 @@ 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;
}
@@ -3383,9 +3294,7 @@ 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;
}
@@ -3511,8 +3420,7 @@ export class RespawnController extends EventEmitter {
}
// Calculate success rate
agg.successRate =
agg.totalCycles > 0 ? Math.round((agg.successfulCycles / agg.totalCycles) * 100) : 100;
agg.successRate = agg.totalCycles > 0 ? Math.round((agg.successfulCycles / agg.totalCycles) * 100) : 100;
agg.lastUpdatedAt = Date.now();
}
@@ -3664,27 +3572,19 @@ 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) {
@@ -3702,10 +3602,10 @@ export class RespawnController extends EventEmitter {
status: HealthStatus,
components: RalphLoopHealthScore['components']
): string {
const lowest = Object.entries(components).reduce(
(min, [key, val]) => (val < min.val ? { key, val } : min),
{ key: '', val: 100 }
);
const lowest = Object.entries(components).reduce((min, [key, val]) => (val < min.val ? { key, val } : min), {
key: '',
val: 100,
});
if (status === 'excellent') {
return `Ralph Loop is operating excellently (${score}/100). All systems healthy.`;
+10 -10
View File
@@ -203,8 +203,7 @@ 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(
@@ -212,7 +211,11 @@ export class RunSummaryTracker {
'info',
`Token milestone: ${this.formatTokens(currentMilestone)}`,
`Input: ${this.formatTokens(inputTokens)}, Output: ${this.formatTokens(outputTokens)}`,
{ total, input: inputTokens, output: outputTokens }
{
total,
input: inputTokens,
output: outputTokens,
}
);
}
}
@@ -303,13 +306,10 @@ export class RunSummaryTracker {
* Record session started.
*/
recordSessionStarted(mode: string, workingDir: string): void {
this.addEvent(
'session_started',
'success',
'Session started',
`Mode: ${mode}, Dir: ${workingDir}`,
{ mode, workingDir }
);
this.addEvent('session_started', 'success', 'Session started', `Mode: ${mode}, Dir: ${workingDir}`, {
mode,
workingDir,
});
}
/**
+13 -50
View File
@@ -37,12 +37,7 @@ import { RalphTracker } from './ralph-tracker.js';
import { BashToolParser } from './bash-tool-parser.js';
import { BufferAccumulator } from './utils/buffer-accumulator.js';
import { LRUMap } from './utils/lru-map.js';
import {
ANSI_ESCAPE_PATTERN_FULL,
TOKEN_PATTERN,
SPINNER_PATTERN,
MAX_SESSION_TOKENS,
} from './utils/index.js';
import { ANSI_ESCAPE_PATTERN_FULL, TOKEN_PATTERN, SPINNER_PATTERN, MAX_SESSION_TOKENS } from './utils/index.js';
import {
MAX_TERMINAL_BUFFER_SIZE,
TRIM_TERMINAL_TO as TERMINAL_BUFFER_TRIM_SIZE,
@@ -263,10 +258,7 @@ 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;
@@ -944,8 +936,7 @@ export class Session extends EventEmitter {
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : 'Claude';
console.log(
`[Session] Starting interactive ${modeLabel} session` +
(this._useMux ? ` (with ${this._mux!.backend})` : '')
`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '')
);
// If mux wrapping is enabled, create or attach to a mux session
@@ -953,10 +944,7 @@ export class Session extends EventEmitter {
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;
}
@@ -1359,10 +1347,7 @@ export class Session extends EventEmitter {
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;
}
@@ -1569,13 +1554,7 @@ export class Session extends EventEmitter {
model ? `(model: ${model})` : ''
);
const args = [
'-p',
'--verbose',
'--dangerously-skip-permissions',
'--output-format',
'stream-json',
];
const args = ['-p', '--verbose', '--dangerously-skip-permissions', '--output-format', 'stream-json'];
if (model) {
args.push('--model', model);
}
@@ -1663,11 +1642,7 @@ 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';
@@ -1745,8 +1720,7 @@ 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;
((msg as unknown as Record<string, unknown>).sessionId as string | undefined) ?? msg.session_id;
if (msgSessionId && !this._claudeSessionId) {
this._claudeSessionId = msgSessionId;
}
@@ -1938,9 +1912,7 @@ 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;
}
@@ -2057,9 +2029,7 @@ 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 () => {
@@ -2072,9 +2042,7 @@ export class Session extends EventEmitter {
if (this._isStopped) return;
// Send /compact command with optional prompt
const compactCmd = this._autoCompactPrompt
? `/compact ${this._autoCompactPrompt}\r`
: '/compact\r';
const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r';
await this.writeViaMux(compactCmd);
this.emit('autoCompact', {
tokens: totalTokens,
@@ -2113,9 +2081,7 @@ 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 () => {
@@ -2370,10 +2336,7 @@ 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
+11 -45
View File
@@ -14,15 +14,7 @@
* @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';
@@ -195,9 +187,7 @@ 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;
}
@@ -211,10 +201,7 @@ 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');
@@ -277,9 +264,7 @@ 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;
}
@@ -292,10 +277,7 @@ 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');
@@ -510,9 +492,7 @@ 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
@@ -544,10 +524,7 @@ export class StateStore {
* @param activeSessions Map of active session states
*/
getAggregateStats(
activeSessions: Record<
string,
{ inputTokens?: number; outputTokens?: number; totalCost?: number }
>
activeSessions: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }>
): {
totalInputTokens: number;
totalOutputTokens: number;
@@ -631,9 +608,7 @@ 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;
}
@@ -662,10 +637,7 @@ export class StateStore {
// Accumulate tokens
todayEntry.inputTokens += inputTokens;
todayEntry.outputTokens += outputTokens;
todayEntry.estimatedCost = this.calculateEstimatedCost(
todayEntry.inputTokens,
todayEntry.outputTokens
);
todayEntry.estimatedCost = this.calculateEstimatedCost(todayEntry.inputTokens, todayEntry.outputTokens);
// Only increment session count for unique sessions
if (sessionId && !this.dailySessionIds.has(sessionId)) {
@@ -740,10 +712,7 @@ 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;
}
@@ -761,10 +730,7 @@ 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
}
+17 -72
View File
@@ -170,20 +170,14 @@ 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>();
@@ -364,10 +358,7 @@ export class SubagentWatcher extends EventEmitter {
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
}
@@ -829,10 +820,7 @@ 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 {
@@ -843,9 +831,7 @@ export class SubagentWatcher extends EventEmitter {
const text = content.text.trim();
if (text.length > 0) {
const preview =
text.length > TEXT_PREVIEW_LENGTH
? text.substring(0, TEXT_PREVIEW_LENGTH) + '...'
: text;
text.length > TEXT_PREVIEW_LENGTH ? text.substring(0, TEXT_PREVIEW_LENGTH) + '...' : text;
lines.push(`${this.formatTime(entry.timestamp)} 💬 ${preview.replace(/\n/g, ' ')}`);
}
}
@@ -856,18 +842,14 @@ 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)}`);
}
}
}
@@ -987,11 +969,7 @@ export class SubagentWatcher extends EventEmitter {
for (const line of lines) {
try {
const entry = JSON.parse(line);
if (
entry.type === 'user' &&
entry.toolUseResult?.agentId &&
entry.toolUseResult?.description
) {
if (entry.type === 'user' && entry.toolUseResult?.agentId && entry.toolUseResult?.description) {
descriptions.set(entry.toolUseResult.agentId, entry.toolUseResult.description);
}
} catch {
@@ -1115,11 +1093,7 @@ 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);
@@ -1206,11 +1180,7 @@ 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) {
@@ -1316,11 +1286,7 @@ 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);
@@ -1338,12 +1304,7 @@ export class SubagentWatcher extends EventEmitter {
/**
* Tail a file from a specific position
*/
private async tailFile(
filePath: string,
agentId: string,
sessionId: string,
fromPosition: number
): Promise<number> {
private async tailFile(filePath: string, agentId: string, sessionId: string, fromPosition: number): Promise<number> {
return new Promise((resolve) => {
let position = fromPosition;
@@ -1383,11 +1344,7 @@ 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)
@@ -1410,11 +1367,7 @@ export class SubagentWatcher extends EventEmitter {
// Check if this is first user message and description is missing
if (info && !info.description && entry.type === 'user' && entry.message?.content) {
// First try parent transcript (most reliable)
let description = await this.extractDescriptionFromParentTranscript(
info.projectHash,
info.sessionId,
agentId
);
let description = await this.extractDescriptionFromParentTranscript(info.projectHash, info.sessionId, agentId);
// Fallback: extract smart title from the prompt content
if (!description) {
let text: string | undefined;
@@ -1601,9 +1554,7 @@ 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)) {
@@ -1618,10 +1569,7 @@ 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) {
@@ -1688,10 +1636,7 @@ 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) {
+2 -8
View File
@@ -166,11 +166,7 @@ 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
@@ -214,9 +210,7 @@ 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). */
+2 -7
View File
@@ -51,11 +51,7 @@ const MAX_PENDING_TOOL_USES = 100;
* Used as fallback when JSON parsing doesn't capture the launch.
* Capture group 1: Agent/task type name
*/
const LAUNCH_PATTERNS = [
/Launching\s+(\w+)\s+agent/i,
/Starting\s+(\w+)\s+task/i,
/Spawning\s+(\w+)\s+agent/i,
];
const LAUNCH_PATTERNS = [/Launching\s+(\w+)\s+agent/i, /Starting\s+(\w+)\s+task/i, /Spawning\s+(\w+)\s+agent/i];
/**
* Patterns that indicate a task has completed.
@@ -365,8 +361,7 @@ export class TaskTracker extends EventEmitter {
if (task) {
task.status = block.is_error ? 'failed' : 'completed';
task.endTime = Date.now();
task.output =
typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
task.output = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
// Remove from stack
const stackIndex = this.taskStack.indexOf(toolUseId);
+3 -8
View File
@@ -108,9 +108,7 @@ export class TeamWatcher extends EventEmitter {
messages.push(...msgs);
}
}
return messages.sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
return messages.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
}
/** Check if a session has active teammates (for idle detection) */
@@ -190,8 +188,7 @@ 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);
@@ -256,9 +253,7 @@ export class TeamWatcher extends EventEmitter {
let taskFiles: string[];
try {
taskFiles = (await readdir(teamTaskDir)).filter(
(f) => f.endsWith('.json') && f !== '.lock'
);
taskFiles = (await readdir(teamTaskDir)).filter((f) => f.endsWith('.json') && f !== '.lock');
} catch {
continue;
}
+2 -11
View File
@@ -44,11 +44,7 @@ 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
@@ -75,12 +71,7 @@ export function generateClaudeMd(
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')
+26 -67
View File
@@ -182,9 +182,7 @@ 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');
}
@@ -206,8 +204,7 @@ function buildSpawnCommand(options: {
}): string {
if (options.mode === 'claude') {
// Validate model to prevent command injection
const safeModel =
options.model && /^[a-zA-Z0-9._-]+$/.test(options.model) ? options.model : undefined;
const safeModel = options.model && /^[a-zA-Z0-9._-]+$/.test(options.model) ? options.model : undefined;
const modelFlag = safeModel ? ` --model ${safeModel}` : '';
return `claude${buildClaudePermissionFlags(options.claudeMode, options.allowedTools)} --session-id "${options.sessionId}"${modelFlag}`;
}
@@ -374,17 +371,7 @@ 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)) {
@@ -416,17 +403,13 @@ 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" && `;
}
@@ -623,16 +606,7 @@ 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;
@@ -756,9 +730,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
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;
}
@@ -859,9 +831,7 @@ 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);
@@ -932,11 +902,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
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;
@@ -966,9 +932,7 @@ 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})`);
}
}
}
@@ -1043,7 +1007,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const pgrepOutput = execSync(
`for p in ${sessionPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
{
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}
).trim();
for (const line of pgrepOutput.split('\n')) {
@@ -1069,10 +1036,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Step 3: Single ps call
const pidArray = Array.from(allPids);
if (pidArray.length > 0) {
const psOutput = execSync(
`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
).trim();
const psOutput = execSync(`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
const processStats = new Map<number, { rss: number; cpu: number }>();
for (const line of psOutput.split('\n')) {
@@ -1419,9 +1386,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
// Build target: sessionName.paneId (e.g., "codeman-abc12345.%1")
const target = paneTarget.startsWith('%')
? `${muxName}.${paneTarget}`
: `${muxName}.%${paneTarget}`;
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try {
const hasCarriageReturn = input.includes('\r');
@@ -1470,9 +1435,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return null;
}
const target = paneTarget.startsWith('%')
? `${muxName}.${paneTarget}`
: `${muxName}.%${paneTarget}`;
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try {
return execSync(`tmux capture-pane -p -e -t ${shellescape(target)} -S -5000`, {
@@ -1504,15 +1467,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return false;
}
const target = paneTarget.startsWith('%')
? `${muxName}.${paneTarget}`
: `${muxName}.%${paneTarget}`;
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try {
execSync(
`tmux pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
);
execSync(`tmux pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
return true;
} catch (err) {
console.error('[TmuxManager] Failed to start pipe-pane:', err);
@@ -1534,9 +1495,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return false;
}
const target = paneTarget.startsWith('%')
? `${muxName}.${paneTarget}`
: `${muxName}.%${paneTarget}`;
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try {
execSync(`tmux pipe-pane -t ${shellescape(target)}`, {
+1 -6
View File
@@ -86,12 +86,7 @@ const POLL_INTERVAL_MS = 1000;
const MAX_MESSAGE_LENGTH = 500;
/** Patterns that indicate plan mode / approval prompt */
const PLAN_MODE_PATTERNS = [
/ExitPlanMode/i,
/AskUserQuestion/i,
/Ready for user approval/i,
/approve.*plan/i,
];
const PLAN_MODE_PATTERNS = [/ExitPlanMode/i, /AskUserQuestion/i, /Ready for user approval/i, /approve.*plan/i];
// ========== TranscriptWatcher Class ==========
+1 -4
View File
@@ -133,10 +133,7 @@ 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;
}
+1 -9
View File
@@ -145,15 +145,7 @@ export interface SessionConfig {
/**
* Available session colors for visual differentiation
*/
export type SessionColor =
| 'default'
| 'red'
| 'orange'
| 'yellow'
| 'green'
| 'blue'
| 'purple'
| 'pink';
export type SessionColor = 'default' | 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' | 'pink';
/**
* Current state of a session
+1 -4
View File
@@ -229,10 +229,7 @@ export class CleanupManager implements Disposable {
* @param description - Human-readable description
* @returns Registration ID
*/
registerStream(
stream: { destroy?: () => void; close?: () => void },
description: string
): string {
registerStream(stream: { destroy?: () => void; close?: () => void }, description: string): string {
return this.registerCleanup(
'stream',
() => {
+3 -16
View File
@@ -19,22 +19,9 @@ export {
createAnsiPatternSimple,
stripAnsi,
} from './regex-patterns.js';
export {
MAX_SESSION_TOKENS,
validateTokenCounts,
validateTokensAndCost,
} from './token-validation.js';
export {
stringSimilarity,
normalizePhrase,
fuzzyPhraseMatch,
todoContentHash,
} from './string-similarity.js';
export { MAX_SESSION_TOKENS, validateTokenCounts, validateTokensAndCost } from './token-validation.js';
export { stringSimilarity, normalizePhrase, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
export { assertNever } from './type-safety.js';
export { wrapWithNice } from './nice-wrapper.js';
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
export {
resolveOpenCodeDir,
isOpenCodeAvailable,
getOpenCodeAugmentedPath,
} from './opencode-cli-resolver.js';
export { resolveOpenCodeDir, isOpenCodeAvailable, getOpenCodeAugmentedPath } from './opencode-cli-resolver.js';
+1 -2
View File
@@ -18,8 +18,7 @@
* Note: Has global flag - reset lastIndex before exec() if reusing.
*/
// eslint-disable-next-line no-control-regex
export const ANSI_ESCAPE_PATTERN_FULL =
/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g;
export const ANSI_ESCAPE_PATTERN_FULL = /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g;
/**
* Simple ANSI CSI-only pattern for basic escape code stripping.
+1 -4
View File
@@ -21,10 +21,7 @@ export const MAX_SESSION_TOKENS = 500_000;
* @param outputTokens - Output token count to validate
* @returns Object with isValid flag and optional error reason
*/
export function validateTokenCounts(
inputTokens: number,
outputTokens: number
): { isValid: boolean; reason?: string } {
export function validateTokenCounts(inputTokens: number, outputTokens: number): { isValid: boolean; reason?: string } {
if (inputTokens < 0 || outputTokens < 0) {
return {
isValid: false,
+3 -16
View File
@@ -153,10 +153,7 @@ export const ResizeSchema = z.object({
export const CreateCaseSchema = z.object({
name: z
.string()
.regex(
/^[a-zA-Z0-9_-]+$/,
'Invalid case name format. Use only letters, numbers, hyphens, underscores.'
),
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.'),
description: z.string().max(1000).optional(),
});
@@ -169,10 +166,7 @@ export const CreateCaseSchema = z.object({
export const QuickStartSchema = z.object({
caseName: z
.string()
.regex(
/^[a-zA-Z0-9_-]+$/,
'Invalid case name format. Use only letters, numbers, hyphens, underscores.'
)
.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,
@@ -185,14 +179,7 @@ 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(),
});
+233 -559
View File
File diff suppressed because it is too large Load Diff