diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index 3ebbf91d..63a1b469 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -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 = 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' + ); } /** diff --git a/src/web/public/app.js b/src/web/public/app.js index f03ce867..6a5c100b 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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 += `
+ ${displayName} + ${remainingSec}s +
+
+
+
`; + } + + timersContainer.innerHTML = html; + } + + updateActionLogDisplay() { + const logContainer = this.$('respawnActionLog'); + if (!logContainer) return; + + const actions = this.respawnActionLogs[this.activeSessionId]; + if (!actions || actions.length === 0) { + logContainer.innerHTML = 'No recent actions'; + 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 += `
+ ${time} + [${action.type}] + ${action.detail} +
`; + } + + 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, diff --git a/src/web/public/index.html b/src/web/public/index.html index 9ab19d37..7b6ce045 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -67,19 +67,29 @@ diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 81e7d4c2..c1d1c7bf 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -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; diff --git a/src/web/server.ts b/src/web/server.ts index f385ab3e..3bdad372 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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 }); });