feat: add respawn controller verbosity with countdown timers and bug fixes

- Fix "enable twice" bug: add enabled:true to getModalRespawnConfig()
- Add timer tracking with countdown display in respawn banner
- New events: timerStarted, timerCancelled, timerCompleted, actionLog
- Show active timers with progress bars and recent action log
- Skip remaining timeouts after AI check returns IDLE/WORKING verdict
- Enhanced DetectionStatus with activeTimers, recentActions, currentPhase

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-25 03:57:44 +01:00
co-authored by Claude Opus 4.5
parent ae4c445601
commit d19d6ceddd
5 changed files with 721 additions and 167 deletions
+411 -151
View File
@@ -86,6 +86,13 @@ const PLAN_MODE_SELECTOR_PATTERN = /[❯>]\s*\d+\./;
* Detection layers for multi-signal idle detection.
* Each layer provides a confidence signal that Claude has finished working.
*/
/** Active timer info for UI display */
export interface ActiveTimerInfo {
name: string;
remainingMs: number;
totalMs: number;
}
export interface DetectionStatus {
/** Layer 1: Completion message detected ("for Xm Xs") */
completionMessageDetected: boolean;
@@ -120,6 +127,18 @@ export interface DetectionStatus {
/** What the controller is currently waiting for */
waitingFor: string;
/** Active countdown timers */
activeTimers: ActiveTimerInfo[];
/** Recent action log entries (last 10) */
recentActions: ActionLogEntry[];
/** Current phase description */
currentPhase: string;
/** Next expected action */
nextAction: string;
}
// ========== Buffer Accumulator ==========
@@ -380,6 +399,21 @@ export interface RespawnConfig {
* @event error - Fired on errors
* @event log - Fired for debug logging
*/
/** Timer info for countdown display */
export interface TimerInfo {
name: string;
durationMs: number;
endsAt: number;
reason?: string;
}
/** Action log entry for detailed UI feedback */
export interface ActionLogEntry {
type: string;
detail: string;
timestamp: number;
}
export interface RespawnEvents {
/** State machine transition */
stateChanged: (state: RespawnState, prevState: RespawnState) => void;
@@ -409,6 +443,14 @@ export interface RespawnEvents {
planCheckCompleted: (result: AiPlanCheckResult) => void;
/** AI plan check failed */
planCheckFailed: (error: string) => void;
/** Timer started for countdown display */
timerStarted: (timer: TimerInfo) => void;
/** Timer cancelled */
timerCancelled: (timerName: string, reason?: string) => void;
/** Timer completed */
timerCompleted: (timerName: string) => void;
/** Verbose action log for detailed UI feedback */
actionLog: (action: ActionLogEntry) => void;
/** Error occurred */
error: (error: Error) => void;
/** Debug log message */
@@ -564,6 +606,14 @@ export class RespawnController extends EventEmitter {
/** Fallback timeout for /clear step (ms) - sends /init without waiting for prompt */
private static readonly CLEAR_FALLBACK_TIMEOUT_MS = 10000;
// ========== 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();
/** Recent action log entries (for UI display, max 20) */
private recentActions: ActionLogEntry[] = [];
// ========== Multi-Layer Detection State ==========
/** Layer 1: Timestamp when completion message was detected */
@@ -750,6 +800,68 @@ export class RespawnController extends EventEmitter {
waitingFor = 'Next event';
}
// Determine current phase and next action
let currentPhase: string;
let nextAction: string;
switch (this._state) {
case 'stopped':
currentPhase = 'Stopped';
nextAction = 'Start to begin';
break;
case 'watching':
currentPhase = 'Monitoring for idle';
nextAction = 'Waiting for silence + no working patterns';
break;
case 'confirming_idle':
currentPhase = 'Confirming idle state';
nextAction = 'Waiting for output silence';
break;
case 'ai_checking':
currentPhase = 'AI analyzing terminal';
nextAction = 'Waiting for IDLE/WORKING verdict';
break;
case 'sending_update':
currentPhase = 'Sending update prompt';
nextAction = 'Will send prompt after delay';
break;
case 'waiting_update':
currentPhase = 'Waiting for update to complete';
nextAction = 'Will send /clear when done';
break;
case 'sending_clear':
currentPhase = 'Sending /clear';
nextAction = 'Will clear context';
break;
case 'waiting_clear':
currentPhase = 'Waiting for /clear to complete';
nextAction = 'Will send /init when done';
break;
case 'sending_init':
currentPhase = 'Sending /init';
nextAction = 'Will re-initialize';
break;
case 'waiting_init':
currentPhase = 'Waiting for /init to complete';
nextAction = 'Monitoring for work';
break;
case 'monitoring_init':
currentPhase = 'Monitoring if /init triggered work';
nextAction = 'Kickstart if no work started';
break;
case 'sending_kickstart':
currentPhase = 'Sending kickstart prompt';
nextAction = 'Will send prompt after delay';
break;
case 'waiting_kickstart':
currentPhase = 'Waiting for kickstart to complete';
nextAction = 'Completing cycle';
break;
default:
currentPhase = this._state;
nextAction = 'Processing...';
}
return {
completionMessageDetected,
completionMessageTime: this.completionMessageTime,
@@ -764,6 +876,10 @@ export class RespawnController extends EventEmitter {
confidenceLevel: confidence,
statusText,
waitingFor,
activeTimers: this.getActiveTimers(),
recentActions: this.recentActions.slice(0, 10),
currentPhase,
nextAction,
};
}
@@ -804,6 +920,7 @@ export class RespawnController extends EventEmitter {
const prevState = this._state;
this._state = newState;
this.log(`State: ${prevState} → ${newState}`);
this.logAction('state', `${prevState} → ${newState}`);
this.emit('stateChanged', newState, prevState);
}
@@ -1121,11 +1238,9 @@ export class RespawnController extends EventEmitter {
private checkClearComplete(): void {
this.clearIdleTimer();
// Clear the fallback timer since we got prompt detection
if (this.clearFallbackTimer) {
clearTimeout(this.clearFallbackTimer);
this.clearFallbackTimer = null;
}
this.log('/clear completed (ready indicator)');
this.cancelTrackedTimer('clear-fallback', this.clearFallbackTimer, 'prompt detected');
this.clearFallbackTimer = null;
this.logAction('step', '/clear completed');
this.emit('stepCompleted', 'clear');
if (this.config.sendInit) {
@@ -1163,15 +1278,21 @@ export class RespawnController extends EventEmitter {
this.setState('monitoring_init');
this.terminalBuffer.clear();
this.workingDetected = false;
this.log('Monitoring if /init triggered work...');
this.logAction('step', 'Monitoring if /init triggered work...');
// Give Claude a moment to start working before checking for idle
this.stepTimer = setTimeout(() => {
// If still in monitoring state and no work detected, consider it idle
if (this._state === 'monitoring_init' && !this.workingDetected) {
this.checkMonitoringInitIdle();
}
}, 3000); // 3 second grace period for /init to trigger work
this.stepTimer = this.startTrackedTimer(
'init-monitor',
3000,
() => {
this.stepTimer = null;
// If still in monitoring state and no work detected, consider it idle
if (this._state === 'monitoring_init' && !this.workingDetected) {
this.checkMonitoringInitIdle();
}
},
'grace period for /init'
);
}
/**
@@ -1198,15 +1319,21 @@ export class RespawnController extends EventEmitter {
this.setState('sending_kickstart');
this.terminalBuffer.clear();
this.stepTimer = setTimeout(() => {
const prompt = this.config.kickstartPrompt!;
this.log(`Sending kickstart prompt: "${prompt}"`);
this.session.writeViaScreen(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
this.emit('stepSent', 'kickstart', prompt);
this.setState('waiting_kickstart');
this.promptDetected = false;
this.workingDetected = false;
}, this.config.interStepDelayMs);
this.stepTimer = this.startTrackedTimer(
'step-delay',
this.config.interStepDelayMs,
() => {
this.stepTimer = null;
const prompt = this.config.kickstartPrompt!;
this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`);
this.session.writeViaScreen(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
this.emit('stepSent', 'kickstart', prompt);
this.setState('waiting_kickstart');
this.promptDetected = false;
this.workingDetected = false;
},
'delay before kickstart'
);
}
/**
@@ -1262,6 +1389,80 @@ export class RespawnController extends EventEmitter {
clearTimeout(this.noOutputTimer);
this.noOutputTimer = null;
}
// Clear all tracked timers
this.activeTimers.clear();
}
// ========== Timer Tracking Methods ==========
/**
* 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 {
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})` : ''}`);
return setTimeout(() => {
this.activeTimers.delete(name);
this.emit('timerCompleted', name);
callback();
}, durationMs);
}
/**
* Cancel a tracked timer and emit cancellation event.
*/
private cancelTrackedTimer(name: string, timerRef: NodeJS.Timeout | null, reason?: string): void {
if (timerRef) {
clearTimeout(timerRef);
if (this.activeTimers.has(name)) {
this.activeTimers.delete(name);
this.emit('timerCancelled', name, reason);
this.logAction('timer-cancel', `${name}${reason ? `: ${reason}` : ''}`);
}
}
}
/**
* Get all active timers with remaining time for UI display.
*/
getActiveTimers(): ActiveTimerInfo[] {
const now = Date.now();
return Array.from(this.activeTimers.values()).map(t => ({
name: t.name,
remainingMs: Math.max(0, t.endsAt - now),
totalMs: t.durationMs,
}));
}
/**
* Log an action for detailed UI feedback.
* Keeps the last 20 entries.
*/
private logAction(type: string, detail: string): void {
const action: ActionLogEntry = { type, detail, timestamp: Date.now() };
this.recentActions.unshift(action);
if (this.recentActions.length > 20) {
this.recentActions.pop();
}
this.emit('actionLog', action);
}
/**
* Get recent action log entries.
*/
getRecentActions(): ActionLogEntry[] {
return [...this.recentActions];
}
// ========== Multi-Layer Detection Methods ==========
@@ -1303,21 +1504,27 @@ export class RespawnController extends EventEmitter {
* (used when AI check is disabled or has too many errors).
*/
private startNoOutputTimer(): void {
if (this.noOutputTimer) {
clearTimeout(this.noOutputTimer);
}
this.noOutputTimer = setTimeout(() => {
if (this._state === 'watching' || this._state === 'confirming_idle') {
const msSinceOutput = Date.now() - this.lastOutputTime;
this.log(`No-output fallback triggered (${msSinceOutput}ms since last output)`);
// 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)');
} else {
this.tryStartAiCheck('no-output fallback');
this.cancelTrackedTimer('no-output-fallback', this.noOutputTimer, 'restarting');
this.noOutputTimer = null;
this.noOutputTimer = this.startTrackedTimer(
'no-output-fallback',
this.config.noOutputTimeoutMs,
() => {
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`);
// 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)');
} else {
this.tryStartAiCheck('no-output fallback');
}
}
}
}, this.config.noOutputTimeoutMs);
},
'fallback if no output at all'
);
}
/**
@@ -1337,30 +1544,36 @@ export class RespawnController extends EventEmitter {
* This provides an additional path to AI check even without a completion message.
*/
private startPreFilterTimer(): void {
if (this.preFilterTimer) {
clearTimeout(this.preFilterTimer);
}
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'restarting');
this.preFilterTimer = null;
// Only set up pre-filter when AI check is enabled
if (!this.config.aiIdleCheckEnabled) return;
this.preFilterTimer = setTimeout(() => {
if (this._state === 'watching') {
const now = Date.now();
const msSinceOutput = now - this.lastOutputTime;
const msSinceWorking = now - this.lastWorkingPatternTime;
const msSinceTokenChange = now - this.lastTokenChangeTime;
this.preFilterTimer = this.startTrackedTimer(
'pre-filter',
this.config.completionConfirmMs,
() => {
this.preFilterTimer = null;
if (this._state === 'watching') {
const now = Date.now();
const msSinceOutput = now - this.lastOutputTime;
const msSinceWorking = now - this.lastWorkingPatternTime;
const msSinceTokenChange = now - this.lastTokenChangeTime;
// Check pre-filter conditions
const silenceMet = msSinceOutput >= this.config.completionConfirmMs;
const noWorkingMet = msSinceWorking >= 3000;
const tokensStableMet = msSinceTokenChange >= this.config.completionConfirmMs;
// Check pre-filter conditions
const silenceMet = msSinceOutput >= this.config.completionConfirmMs;
const noWorkingMet = msSinceWorking >= 3000;
const tokensStableMet = msSinceTokenChange >= this.config.completionConfirmMs;
if (silenceMet && noWorkingMet && tokensStableMet) {
this.log(`Pre-filter conditions met: silence=${msSinceOutput}ms, noWorking=${msSinceWorking}ms, tokensStable=${msSinceTokenChange}ms`);
this.tryStartAiCheck('pre-filter');
if (silenceMet && noWorkingMet && tokensStableMet) {
this.logAction('detection', `Pre-filter passed: silence=${Math.round(msSinceOutput / 1000)}s`);
this.tryStartAiCheck('pre-filter');
}
}
}
}, this.config.completionConfirmMs);
},
'checking idle conditions'
);
}
/**
@@ -1410,7 +1623,7 @@ export class RespawnController extends EventEmitter {
*/
private startAiCheck(reason: string): void {
this.setState('ai_checking');
this.log(`Starting AI idle check (triggered by: ${reason})`);
this.logAction('ai-check', `Spawning AI idle checker (${reason})`);
this.emit('aiCheckStarted');
// Get the terminal buffer for analysis
@@ -1424,9 +1637,21 @@ 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.completionConfirmTimer = null;
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'AI verdict: IDLE');
this.preFilterTimer = null;
this.logAction('ai-check', `Verdict: IDLE - ${result.reasoning}`);
this.emit('aiCheckCompleted', result);
this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`);
} else if (result.verdict === 'WORKING') {
// Cancel timers and go to cooldown
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: WORKING');
this.completionConfirmTimer = null;
this.logAction('ai-check', `Verdict: 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`);
@@ -1435,6 +1660,7 @@ export class RespawnController extends EventEmitter {
this.startPreFilterTimer();
} else {
// ERROR verdict
this.logAction('ai-check', `Error: ${result.reasoning}`);
this.emit('aiCheckFailed', result.reasoning);
this.setState('watching');
// Restart timers to allow retry
@@ -1444,6 +1670,7 @@ export class RespawnController extends EventEmitter {
}).catch((err) => {
if (this._state === 'ai_checking') {
const errorMsg = err instanceof Error ? err.message : String(err);
this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`);
this.emit('aiCheckFailed', errorMsg);
this.setState('watching');
this.log(`AI check error: ${errorMsg}`);
@@ -1472,13 +1699,18 @@ export class RespawnController extends EventEmitter {
* and no elicitation dialog was detected. Only handles plan mode approvals.
*/
private startAutoAcceptTimer(): void {
if (this.autoAcceptTimer) {
clearTimeout(this.autoAcceptTimer);
}
this.autoAcceptTimer = setTimeout(() => {
this.autoAcceptTimer = null;
this.tryAutoAccept();
}, this.config.autoAcceptDelayMs);
this.cancelTrackedTimer('auto-accept', this.autoAcceptTimer, 'restarting');
this.autoAcceptTimer = null;
this.autoAcceptTimer = this.startTrackedTimer(
'auto-accept',
this.config.autoAcceptDelayMs,
() => {
this.autoAcceptTimer = null;
this.tryAutoAccept();
},
'plan mode detection'
);
}
/**
@@ -1486,10 +1718,8 @@ export class RespawnController extends EventEmitter {
* Called when a completion message is detected (normal idle flow handles it).
*/
private cancelAutoAcceptTimer(): void {
if (this.autoAcceptTimer) {
clearTimeout(this.autoAcceptTimer);
this.autoAcceptTimer = null;
}
this.cancelTrackedTimer('auto-accept', this.autoAcceptTimer, 'cancelled');
this.autoAcceptTimer = null;
}
/**
@@ -1593,32 +1823,33 @@ export class RespawnController extends EventEmitter {
*/
private startPlanCheck(buffer: string): void {
this.planCheckStartTime = Date.now();
this.log('Starting AI plan check for auto-accept confirmation');
this.logAction('plan-check', 'Spawning AI plan checker');
this.emit('planCheckStarted');
this.planChecker.check(buffer).then((result) => {
// Discard stale result if new output arrived during check
if (this.lastOutputTime > this.planCheckStartTime) {
this.log('Plan check result discarded (output arrived during check)');
this.logAction('plan-check', 'Result discarded (output arrived during check)');
return;
}
if (result.verdict === 'PLAN_MODE') {
this.emit('planCheckCompleted', result);
this.log(`Plan check confirmed PLAN_MODE, sending Enter`);
this.logAction('plan-check', 'Verdict: PLAN_MODE - sending Enter immediately');
this.sendAutoAcceptEnter();
// No cooldown needed - we're taking action
} else if (result.verdict === 'NOT_PLAN_MODE') {
this.emit('planCheckCompleted', result);
this.log(`Plan check says NOT_PLAN_MODE, skipping auto-accept (cooldown ${this.config.aiPlanCheckCooldownMs}ms)`);
this.logAction('plan-check', `Verdict: NOT_PLAN_MODE - ${result.reasoning}`);
} else {
// ERROR verdict
this.emit('planCheckFailed', result.reasoning);
this.log(`Plan check error: ${result.reasoning}, skipping auto-accept`);
this.logAction('plan-check', `Error: ${result.reasoning}`);
}
}).catch((err) => {
const errorMsg = err instanceof Error ? err.message : String(err);
this.emit('planCheckFailed', errorMsg);
this.log(`Plan check error: ${errorMsg}, skipping auto-accept`);
this.logAction('plan-check', `Failed: ${errorMsg.substring(0, 50)}`);
});
}
@@ -1652,24 +1883,29 @@ export class RespawnController extends EventEmitter {
* After completion message, waits for output silence then triggers AI check.
*/
private startCompletionConfirmTimer(): void {
if (this.completionConfirmTimer) {
clearTimeout(this.completionConfirmTimer);
}
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'restarting');
this.completionConfirmTimer = null;
this.setState('confirming_idle');
this.log(`Completion message detected, waiting ${this.config.completionConfirmMs}ms for silence...`);
this.logAction('detection', 'Completion message found in output');
this.completionConfirmTimer = setTimeout(() => {
const msSinceOutput = Date.now() - this.lastOutputTime;
if (msSinceOutput >= this.config.completionConfirmMs) {
this.log(`Silence confirmed after completion message (${msSinceOutput}ms)`);
this.tryStartAiCheck('completion + silence');
} else {
// Output received during wait, stay in confirming state and re-check
this.log(`Output received during confirmation, resetting timer`);
this.startCompletionConfirmTimer();
}
}, this.config.completionConfirmMs);
this.completionConfirmTimer = this.startTrackedTimer(
'completion-confirm',
this.config.completionConfirmMs,
() => {
this.completionConfirmTimer = null;
const msSinceOutput = Date.now() - this.lastOutputTime;
if (msSinceOutput >= this.config.completionConfirmMs) {
this.logAction('detection', `Silence confirmed: ${Math.round(msSinceOutput / 1000)}s`);
this.tryStartAiCheck('completion + silence');
} else {
// Output received during wait, stay in confirming state and re-check
this.logAction('detection', 'Output during confirmation, resetting');
this.startCompletionConfirmTimer();
}
},
'waiting for silence after completion'
);
}
/**
@@ -1692,39 +1928,39 @@ export class RespawnController extends EventEmitter {
* This ensures Claude has finished processing before we send the next command.
*/
private startStepConfirmTimer(step: 'update' | 'init' | 'kickstart'): void {
// Clear any existing step confirm timer
if (this.stepConfirmTimer) {
clearTimeout(this.stepConfirmTimer);
}
this.cancelTrackedTimer('step-confirm', this.stepConfirmTimer, 'restarting');
this.stepConfirmTimer = null;
this.log(`Step '${step}' completion detected, waiting ${this.config.completionConfirmMs}ms for silence...`);
this.stepConfirmTimer = setTimeout(() => {
const msSinceOutput = Date.now() - this.lastOutputTime;
if (msSinceOutput >= this.config.completionConfirmMs) {
this.log(`Step '${step}' confirmed: ${msSinceOutput}ms silence`);
this.stepConfirmTimer = this.startTrackedTimer(
'step-confirm',
this.config.completionConfirmMs,
() => {
this.stepConfirmTimer = null;
const msSinceOutput = Date.now() - this.lastOutputTime;
// Proceed with the step completion
switch (step) {
case 'update':
this.checkUpdateComplete();
break;
case 'init':
this.checkInitComplete();
break;
case 'kickstart':
this.checkKickstartComplete();
break;
if (msSinceOutput >= this.config.completionConfirmMs) {
this.logAction('step', `${step} confirmed after ${Math.round(msSinceOutput / 1000)}s silence`);
// Proceed with the step completion
switch (step) {
case 'update':
this.checkUpdateComplete();
break;
case 'init':
this.checkInitComplete();
break;
case 'kickstart':
this.checkKickstartComplete();
break;
}
} else {
// Output received during wait, restart timer
this.logAction('step', `Output during ${step} confirmation, resetting`);
this.startStepConfirmTimer(step);
}
} else {
// Output received during wait, restart timer
this.log(`Output during step confirmation, resetting timer`);
this.startStepConfirmTimer(step);
}
}, this.config.completionConfirmMs);
},
`confirming ${step} completion`
);
}
/**
@@ -1786,15 +2022,21 @@ export class RespawnController extends EventEmitter {
this.setState('sending_update');
this.terminalBuffer.clear(); // Clear buffer for fresh detection
this.stepTimer = setTimeout(() => {
const input = this.config.updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI
this.log(`Sending update prompt: "${this.config.updatePrompt}"`);
this.session.writeViaScreen(input);
this.emit('stepSent', 'update', this.config.updatePrompt);
this.setState('waiting_update');
this.promptDetected = false;
this.workingDetected = false;
}, this.config.interStepDelayMs);
this.stepTimer = this.startTrackedTimer(
'step-delay',
this.config.interStepDelayMs,
() => {
this.stepTimer = null;
const input = this.config.updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI
this.logAction('command', `Sending: "${this.config.updatePrompt.substring(0, 50)}..."`);
this.session.writeViaScreen(input);
this.emit('stepSent', 'update', this.config.updatePrompt);
this.setState('waiting_update');
this.promptDetected = false;
this.workingDetected = false;
},
'delay before update prompt'
);
}
/**
@@ -1807,26 +2049,38 @@ export class RespawnController extends EventEmitter {
this.setState('sending_clear');
this.terminalBuffer.clear();
this.stepTimer = setTimeout(() => {
this.log('Sending /clear');
this.session.writeViaScreen('/clear\r'); // \r triggers Enter in Ink/Claude CLI
this.emit('stepSent', 'clear', '/clear');
this.setState('waiting_clear');
this.promptDetected = false;
this.stepTimer = this.startTrackedTimer(
'step-delay',
this.config.interStepDelayMs,
() => {
this.stepTimer = null;
this.logAction('command', 'Sending: /clear');
this.session.writeViaScreen('/clear\r'); // \r triggers Enter in Ink/Claude CLI
this.emit('stepSent', 'clear', '/clear');
this.setState('waiting_clear');
this.promptDetected = false;
// Start fallback timer - if no prompt detected after 10s, proceed to /init anyway
this.clearFallbackTimer = setTimeout(() => {
if (this._state === 'waiting_clear') {
this.log('/clear fallback: no prompt detected after 10s, proceeding to /init');
this.emit('stepCompleted', 'clear');
if (this.config.sendInit) {
this.sendInit();
} else {
this.completeCycle();
}
}
}, RespawnController.CLEAR_FALLBACK_TIMEOUT_MS);
}, this.config.interStepDelayMs);
// Start fallback timer - if no prompt detected after 10s, proceed to /init anyway
this.clearFallbackTimer = this.startTrackedTimer(
'clear-fallback',
RespawnController.CLEAR_FALLBACK_TIMEOUT_MS,
() => {
this.clearFallbackTimer = null;
if (this._state === 'waiting_clear') {
this.logAction('step', '/clear fallback: proceeding to /init');
this.emit('stepCompleted', 'clear');
if (this.config.sendInit) {
this.sendInit();
} else {
this.completeCycle();
}
}
},
'fallback if no prompt after /clear'
);
},
'delay before /clear'
);
}
/**
@@ -1837,14 +2091,20 @@ export class RespawnController extends EventEmitter {
this.setState('sending_init');
this.terminalBuffer.clear();
this.stepTimer = setTimeout(() => {
this.log('Sending /init');
this.session.writeViaScreen('/init\r'); // \r triggers Enter in Ink/Claude CLI
this.emit('stepSent', 'init', '/init');
this.setState('waiting_init');
this.promptDetected = false;
this.workingDetected = false;
}, this.config.interStepDelayMs);
this.stepTimer = this.startTrackedTimer(
'step-delay',
this.config.interStepDelayMs,
() => {
this.stepTimer = null;
this.logAction('command', 'Sending: /init');
this.session.writeViaScreen('/init\r'); // \r triggers Enter in Ink/Claude CLI
this.emit('stepSent', 'init', '/init');
this.setState('waiting_init');
this.promptDetected = false;
this.workingDetected = false;
},
'delay before /init'
);
}
/**
+171 -1
View File
@@ -361,6 +361,9 @@ class ClaudemanApp {
this.activeSessionId = null;
this.respawnStatus = {};
this.respawnTimers = {}; // Track timed respawn timers
this.respawnCountdownTimers = {}; // { sessionId: { timerName: { endsAt, totalMs, reason } } }
this.respawnActionLogs = {}; // { sessionId: [action, action, ...] } (max 20)
this.timerCountdownInterval = null; // Interval for updating countdown display
this.terminalBuffers = new Map(); // Store terminal content per session
this.editingSessionId = null; // Session being edited in options modal
this.pendingCloseSessionId = null; // Session pending close confirmation
@@ -977,7 +980,7 @@ class ClaudemanApp {
}
});
// Respawn timer events
// Respawn run timer events (timed respawn runs)
this.eventSource.addEventListener('respawn:timerStarted', (e) => {
const data = JSON.parse(e.data);
this.respawnTimers[data.sessionId] = {
@@ -990,6 +993,60 @@ class ClaudemanApp {
}
});
// Respawn controller countdown timer events (internal timers)
this.eventSource.addEventListener('respawn:timerStarted', (e) => {
const data = JSON.parse(e.data);
// This may fire for both run timers and controller timers - check for timer object
if (data.timer) {
const { sessionId, timer } = data;
if (!this.respawnCountdownTimers[sessionId]) {
this.respawnCountdownTimers[sessionId] = {};
}
this.respawnCountdownTimers[sessionId][timer.name] = {
endsAt: timer.endsAt,
totalMs: timer.durationMs,
reason: timer.reason
};
if (sessionId === this.activeSessionId) {
this.updateCountdownTimerDisplay();
this.startCountdownInterval();
}
}
});
this.eventSource.addEventListener('respawn:timerCancelled', (e) => {
const data = JSON.parse(e.data);
const { sessionId, timerName, reason } = data;
if (this.respawnCountdownTimers[sessionId]) {
delete this.respawnCountdownTimers[sessionId][timerName];
}
if (sessionId === this.activeSessionId) {
this.updateCountdownTimerDisplay();
// Add to action log
this.addActionLogEntry(sessionId, { type: 'timer-cancel', detail: `${timerName}${reason ? ': ' + reason : ''}`, timestamp: Date.now() });
}
});
this.eventSource.addEventListener('respawn:timerCompleted', (e) => {
const data = JSON.parse(e.data);
const { sessionId, timerName } = data;
if (this.respawnCountdownTimers[sessionId]) {
delete this.respawnCountdownTimers[sessionId][timerName];
}
if (sessionId === this.activeSessionId) {
this.updateCountdownTimerDisplay();
}
});
this.eventSource.addEventListener('respawn:actionLog', (e) => {
const data = JSON.parse(e.data);
const { sessionId, action } = data;
this.addActionLogEntry(sessionId, action);
if (sessionId === this.activeSessionId) {
this.updateActionLogDisplay();
}
});
// Auto-clear event
this.eventSource.addEventListener('session:autoClear', (e) => {
const data = JSON.parse(e.data);
@@ -1468,8 +1525,15 @@ class ClaudemanApp {
this.showRespawnBanner();
this.updateRespawnBanner(this.respawnStatus[sessionId].state);
document.getElementById('respawnCycleCount').textContent = this.respawnStatus[sessionId].cycleCount || 0;
// Update countdown timers and action log for this session
this.updateCountdownTimerDisplay();
this.updateActionLogDisplay();
if (Object.keys(this.respawnCountdownTimers[sessionId] || {}).length > 0) {
this.startCountdownInterval();
}
} else {
this.hideRespawnBanner();
this.stopCountdownInterval();
}
// Update task panel if open
@@ -1501,6 +1565,7 @@ class ClaudemanApp {
this.sessions.delete(sessionId);
this.terminalBuffers.delete(sessionId);
this.ralphStates.delete(sessionId);
this.clearCountdownTimers(sessionId);
if (this.activeSessionId === sessionId) {
this.activeSessionId = null;
@@ -2011,11 +2076,112 @@ class ClaudemanApp {
}
}
// ========== Countdown Timer Display Methods ==========
addActionLogEntry(sessionId, action) {
if (!this.respawnActionLogs[sessionId]) {
this.respawnActionLogs[sessionId] = [];
}
this.respawnActionLogs[sessionId].unshift(action);
if (this.respawnActionLogs[sessionId].length > 20) {
this.respawnActionLogs[sessionId].pop();
}
}
startCountdownInterval() {
if (this.timerCountdownInterval) return;
this.timerCountdownInterval = setInterval(() => {
if (this.activeSessionId && this.respawnCountdownTimers[this.activeSessionId]) {
this.updateCountdownTimerDisplay();
}
}, 100);
}
stopCountdownInterval() {
if (this.timerCountdownInterval) {
clearInterval(this.timerCountdownInterval);
this.timerCountdownInterval = null;
}
}
updateCountdownTimerDisplay() {
const timersRow = this.$('respawnTimersRow');
const timersContainer = this.$('respawnCountdownTimers');
if (!timersRow || !timersContainer) return;
const timers = this.respawnCountdownTimers[this.activeSessionId];
if (!timers || Object.keys(timers).length === 0) {
timersRow.style.display = 'none';
return;
}
timersRow.style.display = '';
const now = Date.now();
let html = '';
for (const [name, timer] of Object.entries(timers)) {
const remainingMs = Math.max(0, timer.endsAt - now);
const remainingSec = (remainingMs / 1000).toFixed(1);
const percent = Math.max(0, Math.min(100, (remainingMs / timer.totalMs) * 100));
// Format timer name for display (replace hyphens with spaces, capitalize first letter)
const displayName = name.replace(/-/g, ' ').replace(/^\w/, c => c.toUpperCase());
html += `<div class="respawn-countdown-timer" title="${timer.reason || ''}">
<span class="timer-name">${displayName}</span>
<span class="timer-value">${remainingSec}s</span>
<div class="respawn-timer-bar">
<div class="respawn-timer-progress" style="width: ${percent}%"></div>
</div>
</div>`;
}
timersContainer.innerHTML = html;
}
updateActionLogDisplay() {
const logContainer = this.$('respawnActionLog');
if (!logContainer) return;
const actions = this.respawnActionLogs[this.activeSessionId];
if (!actions || actions.length === 0) {
logContainer.innerHTML = '<span class="action-empty">No recent actions</span>';
return;
}
let html = '';
for (const action of actions.slice(0, 10)) {
const time = new Date(action.timestamp).toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
html += `<div class="respawn-action-entry">
<span class="action-time">${time}</span>
<span class="action-type">[${action.type}]</span>
<span class="action-detail">${action.detail}</span>
</div>`;
}
logContainer.innerHTML = html;
}
clearCountdownTimers(sessionId) {
delete this.respawnCountdownTimers[sessionId];
delete this.respawnActionLogs[sessionId];
if (sessionId === this.activeSessionId) {
this.updateCountdownTimerDisplay();
this.updateActionLogDisplay();
}
}
async stopRespawn() {
if (!this.activeSessionId) return;
try {
await fetch(`/api/sessions/${this.activeSessionId}/respawn/stop`, { method: 'POST' });
delete this.respawnTimers[this.activeSessionId];
this.clearCountdownTimers(this.activeSessionId);
} catch (err) {
this.showToast('Failed to stop respawn', 'error');
}
@@ -2042,6 +2208,9 @@ class ClaudemanApp {
this.terminalBuffers.clear();
this.activeSessionId = null;
this.respawnStatus = {};
this.respawnCountdownTimers = {};
this.respawnActionLogs = {};
this.stopCountdownInterval();
this.hideRespawnBanner();
this.renderSessionTabs();
this.terminal.clear();
@@ -2397,6 +2566,7 @@ class ClaudemanApp {
return {
respawnConfig: {
enabled: true, // Fix: ensure enabled is set so pre-saved configs with enabled: false get overridden
updatePrompt,
sendClear,
sendInit,
+23 -13
View File
@@ -67,19 +67,29 @@
<!-- Respawn Banner -->
<div class="respawn-banner" id="respawnBanner" style="display: none;">
<span class="respawn-indicator">&#x21BB;</span>
<span class="respawn-state" id="respawnState">Watching</span>
<span class="respawn-detection" id="respawnDetection">
<span class="detection-status" id="detectionStatus"></span>
<span class="detection-waiting" id="detectionWaiting"></span>
<span class="detection-confidence" id="detectionConfidence"></span>
<span class="detection-ai-check" id="detectionAiCheck" style="display: none;"></span>
</span>
<span class="respawn-cycles">Cycle <span id="respawnCycleCount">0</span></span>
<span class="respawn-timer" id="respawnTimer" style="display: none;"></span>
<span class="respawn-tokens" id="respawnTokens" style="display: none;"></span>
<span class="respawn-step" id="respawnStep"></span>
<button class="btn-icon-only" onclick="app.stopRespawn()" title="Stop Respawn">&#x25A0;</button>
<div class="respawn-banner-row">
<span class="respawn-indicator">&#x21BB;</span>
<span class="respawn-state" id="respawnState">Watching</span>
<span class="respawn-detection" id="respawnDetection">
<span class="detection-status" id="detectionStatus"></span>
<span class="detection-waiting" id="detectionWaiting"></span>
<span class="detection-confidence" id="detectionConfidence"></span>
<span class="detection-ai-check" id="detectionAiCheck" style="display: none;"></span>
</span>
<span class="respawn-cycles">Cycle <span id="respawnCycleCount">0</span></span>
<span class="respawn-timer" id="respawnTimer" style="display: none;"></span>
<span class="respawn-tokens" id="respawnTokens" style="display: none;"></span>
<span class="respawn-step" id="respawnStep"></span>
<button class="btn-icon-only" onclick="app.stopRespawn()" title="Stop Respawn">&#x25A0;</button>
</div>
<div class="respawn-timers-row" id="respawnTimersRow" style="display: none;">
<div class="respawn-countdown-timers" id="respawnCountdownTimers">
<!-- Dynamically populated countdown timers -->
</div>
<div class="respawn-action-log" id="respawnActionLog">
<!-- Recent actions scrolling list -->
</div>
</div>
</div>
<!-- Ralph / Todo Tracker Panel -->
+99 -2
View File
@@ -454,8 +454,7 @@ body {
/* Respawn Banner - Compact */
.respawn-banner {
display: flex;
align-items: center;
gap: 0.75rem;
flex-direction: column;
padding: 0.35rem 1rem;
background: linear-gradient(90deg, #1e3a1e, #132513);
border-bottom: 1px solid rgba(34, 197, 94, 0.3);
@@ -463,6 +462,104 @@ body {
flex-shrink: 0;
}
.respawn-banner-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.respawn-timers-row {
display: flex;
align-items: flex-start;
gap: 1rem;
padding-top: 0.35rem;
border-top: 1px solid rgba(34, 197, 94, 0.15);
margin-top: 0.35rem;
}
.respawn-countdown-timers {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.respawn-countdown-timer {
display: flex;
align-items: center;
gap: 0.4rem;
background: rgba(0, 0, 0, 0.35);
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.7rem;
}
.respawn-countdown-timer .timer-name {
color: var(--text-dim);
}
.respawn-countdown-timer .timer-value {
color: var(--green);
font-weight: 500;
min-width: 2.5em;
text-align: right;
}
.respawn-timer-bar {
width: 40px;
height: 3px;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
overflow: hidden;
}
.respawn-timer-progress {
height: 100%;
background: var(--green);
transition: width 0.1s linear;
}
.respawn-action-log {
flex: 1;
max-height: 60px;
overflow-y: auto;
font-size: 0.65rem;
opacity: 0.75;
font-family: 'SF Mono', Monaco, monospace;
color: var(--text-dim);
}
.respawn-action-log::-webkit-scrollbar {
width: 4px;
}
.respawn-action-log::-webkit-scrollbar-thumb {
background: rgba(34, 197, 94, 0.3);
border-radius: 2px;
}
.respawn-action-entry {
padding: 0.1rem 0;
border-bottom: 1px solid rgba(34, 197, 94, 0.1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.respawn-action-entry .action-time {
color: var(--text-muted);
margin-right: 0.4rem;
}
.respawn-action-entry .action-type {
color: var(--green);
margin-right: 0.4rem;
}
.respawn-action-entry .action-detail {
color: var(--text-dim);
}
.respawn-indicator {
color: var(--green);
animation: spin 2s linear infinite;
+17
View File
@@ -1866,6 +1866,23 @@ export class WebServer extends EventEmitter {
this.broadcast('respawn:planCheckFailed', { sessionId, error });
});
// Timer tracking events for UI countdown display
controller.on('timerStarted', (timer) => {
this.broadcast('respawn:timerStarted', { sessionId, timer });
});
controller.on('timerCancelled', (timerName, reason) => {
this.broadcast('respawn:timerCancelled', { sessionId, timerName, reason });
});
controller.on('timerCompleted', (timerName) => {
this.broadcast('respawn:timerCompleted', { sessionId, timerName });
});
controller.on('actionLog', (action) => {
this.broadcast('respawn:actionLog', { sessionId, action });
});
controller.on('log', (message: string) => {
this.broadcast('respawn:log', { sessionId, message });
});