mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
fix layout
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"tabWidth": 2,
|
||||
"printWidth": 100,
|
||||
"printWidth": 120,
|
||||
"trailingComma": "es5",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
|
||||
+13
-37
@@ -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
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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'));
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)}`, {
|
||||
|
||||
@@ -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 ==========
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user