mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
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:
+411
-151
@@ -86,6 +86,13 @@ const PLAN_MODE_SELECTOR_PATTERN = /[❯>]\s*\d+\./;
|
|||||||
* Detection layers for multi-signal idle detection.
|
* Detection layers for multi-signal idle detection.
|
||||||
* Each layer provides a confidence signal that Claude has finished working.
|
* 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 {
|
export interface DetectionStatus {
|
||||||
/** Layer 1: Completion message detected ("for Xm Xs") */
|
/** Layer 1: Completion message detected ("for Xm Xs") */
|
||||||
completionMessageDetected: boolean;
|
completionMessageDetected: boolean;
|
||||||
@@ -120,6 +127,18 @@ export interface DetectionStatus {
|
|||||||
|
|
||||||
/** What the controller is currently waiting for */
|
/** What the controller is currently waiting for */
|
||||||
waitingFor: string;
|
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 ==========
|
// ========== Buffer Accumulator ==========
|
||||||
@@ -380,6 +399,21 @@ export interface RespawnConfig {
|
|||||||
* @event error - Fired on errors
|
* @event error - Fired on errors
|
||||||
* @event log - Fired for debug logging
|
* @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 {
|
export interface RespawnEvents {
|
||||||
/** State machine transition */
|
/** State machine transition */
|
||||||
stateChanged: (state: RespawnState, prevState: RespawnState) => void;
|
stateChanged: (state: RespawnState, prevState: RespawnState) => void;
|
||||||
@@ -409,6 +443,14 @@ export interface RespawnEvents {
|
|||||||
planCheckCompleted: (result: AiPlanCheckResult) => void;
|
planCheckCompleted: (result: AiPlanCheckResult) => void;
|
||||||
/** AI plan check failed */
|
/** AI plan check failed */
|
||||||
planCheckFailed: (error: string) => void;
|
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 occurred */
|
||||||
error: (error: Error) => void;
|
error: (error: Error) => void;
|
||||||
/** Debug log message */
|
/** Debug log message */
|
||||||
@@ -564,6 +606,14 @@ export class RespawnController extends EventEmitter {
|
|||||||
/** Fallback timeout for /clear step (ms) - sends /init without waiting for prompt */
|
/** Fallback timeout for /clear step (ms) - sends /init without waiting for prompt */
|
||||||
private static readonly CLEAR_FALLBACK_TIMEOUT_MS = 10000;
|
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 ==========
|
// ========== Multi-Layer Detection State ==========
|
||||||
|
|
||||||
/** Layer 1: Timestamp when completion message was detected */
|
/** Layer 1: Timestamp when completion message was detected */
|
||||||
@@ -750,6 +800,68 @@ export class RespawnController extends EventEmitter {
|
|||||||
waitingFor = 'Next event';
|
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 {
|
return {
|
||||||
completionMessageDetected,
|
completionMessageDetected,
|
||||||
completionMessageTime: this.completionMessageTime,
|
completionMessageTime: this.completionMessageTime,
|
||||||
@@ -764,6 +876,10 @@ export class RespawnController extends EventEmitter {
|
|||||||
confidenceLevel: confidence,
|
confidenceLevel: confidence,
|
||||||
statusText,
|
statusText,
|
||||||
waitingFor,
|
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;
|
const prevState = this._state;
|
||||||
this._state = newState;
|
this._state = newState;
|
||||||
this.log(`State: ${prevState} → ${newState}`);
|
this.log(`State: ${prevState} → ${newState}`);
|
||||||
|
this.logAction('state', `${prevState} → ${newState}`);
|
||||||
this.emit('stateChanged', newState, prevState);
|
this.emit('stateChanged', newState, prevState);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1121,11 +1238,9 @@ export class RespawnController extends EventEmitter {
|
|||||||
private checkClearComplete(): void {
|
private checkClearComplete(): void {
|
||||||
this.clearIdleTimer();
|
this.clearIdleTimer();
|
||||||
// Clear the fallback timer since we got prompt detection
|
// Clear the fallback timer since we got prompt detection
|
||||||
if (this.clearFallbackTimer) {
|
this.cancelTrackedTimer('clear-fallback', this.clearFallbackTimer, 'prompt detected');
|
||||||
clearTimeout(this.clearFallbackTimer);
|
this.clearFallbackTimer = null;
|
||||||
this.clearFallbackTimer = null;
|
this.logAction('step', '/clear completed');
|
||||||
}
|
|
||||||
this.log('/clear completed (ready indicator)');
|
|
||||||
this.emit('stepCompleted', 'clear');
|
this.emit('stepCompleted', 'clear');
|
||||||
|
|
||||||
if (this.config.sendInit) {
|
if (this.config.sendInit) {
|
||||||
@@ -1163,15 +1278,21 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.setState('monitoring_init');
|
this.setState('monitoring_init');
|
||||||
this.terminalBuffer.clear();
|
this.terminalBuffer.clear();
|
||||||
this.workingDetected = false;
|
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
|
// Give Claude a moment to start working before checking for idle
|
||||||
this.stepTimer = setTimeout(() => {
|
this.stepTimer = this.startTrackedTimer(
|
||||||
// If still in monitoring state and no work detected, consider it idle
|
'init-monitor',
|
||||||
if (this._state === 'monitoring_init' && !this.workingDetected) {
|
3000,
|
||||||
this.checkMonitoringInitIdle();
|
() => {
|
||||||
}
|
this.stepTimer = null;
|
||||||
}, 3000); // 3 second grace period for /init to trigger work
|
// 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.setState('sending_kickstart');
|
||||||
this.terminalBuffer.clear();
|
this.terminalBuffer.clear();
|
||||||
|
|
||||||
this.stepTimer = setTimeout(() => {
|
this.stepTimer = this.startTrackedTimer(
|
||||||
const prompt = this.config.kickstartPrompt!;
|
'step-delay',
|
||||||
this.log(`Sending kickstart prompt: "${prompt}"`);
|
this.config.interStepDelayMs,
|
||||||
this.session.writeViaScreen(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
|
() => {
|
||||||
this.emit('stepSent', 'kickstart', prompt);
|
this.stepTimer = null;
|
||||||
this.setState('waiting_kickstart');
|
const prompt = this.config.kickstartPrompt!;
|
||||||
this.promptDetected = false;
|
this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`);
|
||||||
this.workingDetected = false;
|
this.session.writeViaScreen(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
|
||||||
}, this.config.interStepDelayMs);
|
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);
|
clearTimeout(this.noOutputTimer);
|
||||||
this.noOutputTimer = null;
|
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 ==========
|
// ========== Multi-Layer Detection Methods ==========
|
||||||
@@ -1303,21 +1504,27 @@ export class RespawnController extends EventEmitter {
|
|||||||
* (used when AI check is disabled or has too many errors).
|
* (used when AI check is disabled or has too many errors).
|
||||||
*/
|
*/
|
||||||
private startNoOutputTimer(): void {
|
private startNoOutputTimer(): void {
|
||||||
if (this.noOutputTimer) {
|
this.cancelTrackedTimer('no-output-fallback', this.noOutputTimer, 'restarting');
|
||||||
clearTimeout(this.noOutputTimer);
|
this.noOutputTimer = null;
|
||||||
}
|
|
||||||
this.noOutputTimer = setTimeout(() => {
|
this.noOutputTimer = this.startTrackedTimer(
|
||||||
if (this._state === 'watching' || this._state === 'confirming_idle') {
|
'no-output-fallback',
|
||||||
const msSinceOutput = Date.now() - this.lastOutputTime;
|
this.config.noOutputTimeoutMs,
|
||||||
this.log(`No-output fallback triggered (${msSinceOutput}ms since last output)`);
|
() => {
|
||||||
// If AI check is disabled or errored out, go directly to idle
|
this.noOutputTimer = null;
|
||||||
if (!this.config.aiIdleCheckEnabled || this.aiChecker.status === 'disabled') {
|
if (this._state === 'watching' || this._state === 'confirming_idle') {
|
||||||
this.onIdleConfirmed('no-output fallback (AI check disabled)');
|
const msSinceOutput = Date.now() - this.lastOutputTime;
|
||||||
} else {
|
this.logAction('detection', `No-output fallback: ${Math.round(msSinceOutput / 1000)}s silence`);
|
||||||
this.tryStartAiCheck('no-output fallback');
|
// 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.
|
* This provides an additional path to AI check even without a completion message.
|
||||||
*/
|
*/
|
||||||
private startPreFilterTimer(): void {
|
private startPreFilterTimer(): void {
|
||||||
if (this.preFilterTimer) {
|
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'restarting');
|
||||||
clearTimeout(this.preFilterTimer);
|
this.preFilterTimer = null;
|
||||||
}
|
|
||||||
// Only set up pre-filter when AI check is enabled
|
// Only set up pre-filter when AI check is enabled
|
||||||
if (!this.config.aiIdleCheckEnabled) return;
|
if (!this.config.aiIdleCheckEnabled) return;
|
||||||
|
|
||||||
this.preFilterTimer = setTimeout(() => {
|
this.preFilterTimer = this.startTrackedTimer(
|
||||||
if (this._state === 'watching') {
|
'pre-filter',
|
||||||
const now = Date.now();
|
this.config.completionConfirmMs,
|
||||||
const msSinceOutput = now - this.lastOutputTime;
|
() => {
|
||||||
const msSinceWorking = now - this.lastWorkingPatternTime;
|
this.preFilterTimer = null;
|
||||||
const msSinceTokenChange = now - this.lastTokenChangeTime;
|
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
|
// Check pre-filter conditions
|
||||||
const silenceMet = msSinceOutput >= this.config.completionConfirmMs;
|
const silenceMet = msSinceOutput >= this.config.completionConfirmMs;
|
||||||
const noWorkingMet = msSinceWorking >= 3000;
|
const noWorkingMet = msSinceWorking >= 3000;
|
||||||
const tokensStableMet = msSinceTokenChange >= this.config.completionConfirmMs;
|
const tokensStableMet = msSinceTokenChange >= this.config.completionConfirmMs;
|
||||||
|
|
||||||
if (silenceMet && noWorkingMet && tokensStableMet) {
|
if (silenceMet && noWorkingMet && tokensStableMet) {
|
||||||
this.log(`Pre-filter conditions met: silence=${msSinceOutput}ms, noWorking=${msSinceWorking}ms, tokensStable=${msSinceTokenChange}ms`);
|
this.logAction('detection', `Pre-filter passed: silence=${Math.round(msSinceOutput / 1000)}s`);
|
||||||
this.tryStartAiCheck('pre-filter');
|
this.tryStartAiCheck('pre-filter');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}, this.config.completionConfirmMs);
|
'checking idle conditions'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1410,7 +1623,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
private startAiCheck(reason: string): void {
|
private startAiCheck(reason: string): void {
|
||||||
this.setState('ai_checking');
|
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');
|
this.emit('aiCheckStarted');
|
||||||
|
|
||||||
// Get the terminal buffer for analysis
|
// Get the terminal buffer for analysis
|
||||||
@@ -1424,9 +1637,21 @@ export class RespawnController extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result.verdict === 'IDLE') {
|
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.emit('aiCheckCompleted', result);
|
||||||
this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`);
|
this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`);
|
||||||
} else if (result.verdict === 'WORKING') {
|
} 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.emit('aiCheckCompleted', result);
|
||||||
this.setState('watching');
|
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`);
|
||||||
@@ -1435,6 +1660,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.startPreFilterTimer();
|
this.startPreFilterTimer();
|
||||||
} else {
|
} else {
|
||||||
// ERROR verdict
|
// ERROR verdict
|
||||||
|
this.logAction('ai-check', `Error: ${result.reasoning}`);
|
||||||
this.emit('aiCheckFailed', result.reasoning);
|
this.emit('aiCheckFailed', result.reasoning);
|
||||||
this.setState('watching');
|
this.setState('watching');
|
||||||
// Restart timers to allow retry
|
// Restart timers to allow retry
|
||||||
@@ -1444,6 +1670,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
if (this._state === 'ai_checking') {
|
if (this._state === 'ai_checking') {
|
||||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`);
|
||||||
this.emit('aiCheckFailed', errorMsg);
|
this.emit('aiCheckFailed', errorMsg);
|
||||||
this.setState('watching');
|
this.setState('watching');
|
||||||
this.log(`AI check error: ${errorMsg}`);
|
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.
|
* and no elicitation dialog was detected. Only handles plan mode approvals.
|
||||||
*/
|
*/
|
||||||
private startAutoAcceptTimer(): void {
|
private startAutoAcceptTimer(): void {
|
||||||
if (this.autoAcceptTimer) {
|
this.cancelTrackedTimer('auto-accept', this.autoAcceptTimer, 'restarting');
|
||||||
clearTimeout(this.autoAcceptTimer);
|
this.autoAcceptTimer = null;
|
||||||
}
|
|
||||||
this.autoAcceptTimer = setTimeout(() => {
|
this.autoAcceptTimer = this.startTrackedTimer(
|
||||||
this.autoAcceptTimer = null;
|
'auto-accept',
|
||||||
this.tryAutoAccept();
|
this.config.autoAcceptDelayMs,
|
||||||
}, 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).
|
* Called when a completion message is detected (normal idle flow handles it).
|
||||||
*/
|
*/
|
||||||
private cancelAutoAcceptTimer(): void {
|
private cancelAutoAcceptTimer(): void {
|
||||||
if (this.autoAcceptTimer) {
|
this.cancelTrackedTimer('auto-accept', this.autoAcceptTimer, 'cancelled');
|
||||||
clearTimeout(this.autoAcceptTimer);
|
this.autoAcceptTimer = null;
|
||||||
this.autoAcceptTimer = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1593,32 +1823,33 @@ export class RespawnController extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
private startPlanCheck(buffer: string): void {
|
private startPlanCheck(buffer: string): void {
|
||||||
this.planCheckStartTime = Date.now();
|
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.emit('planCheckStarted');
|
||||||
|
|
||||||
this.planChecker.check(buffer).then((result) => {
|
this.planChecker.check(buffer).then((result) => {
|
||||||
// Discard stale result if new output arrived during check
|
// Discard stale result if new output arrived during check
|
||||||
if (this.lastOutputTime > this.planCheckStartTime) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.verdict === 'PLAN_MODE') {
|
if (result.verdict === 'PLAN_MODE') {
|
||||||
this.emit('planCheckCompleted', result);
|
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();
|
this.sendAutoAcceptEnter();
|
||||||
|
// No cooldown needed - we're taking action
|
||||||
} else if (result.verdict === 'NOT_PLAN_MODE') {
|
} else if (result.verdict === 'NOT_PLAN_MODE') {
|
||||||
this.emit('planCheckCompleted', result);
|
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 {
|
} else {
|
||||||
// ERROR verdict
|
// ERROR verdict
|
||||||
this.emit('planCheckFailed', result.reasoning);
|
this.emit('planCheckFailed', result.reasoning);
|
||||||
this.log(`Plan check error: ${result.reasoning}, skipping auto-accept`);
|
this.logAction('plan-check', `Error: ${result.reasoning}`);
|
||||||
}
|
}
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
this.emit('planCheckFailed', errorMsg);
|
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.
|
* After completion message, waits for output silence then triggers AI check.
|
||||||
*/
|
*/
|
||||||
private startCompletionConfirmTimer(): void {
|
private startCompletionConfirmTimer(): void {
|
||||||
if (this.completionConfirmTimer) {
|
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'restarting');
|
||||||
clearTimeout(this.completionConfirmTimer);
|
this.completionConfirmTimer = null;
|
||||||
}
|
|
||||||
|
|
||||||
this.setState('confirming_idle');
|
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(() => {
|
this.completionConfirmTimer = this.startTrackedTimer(
|
||||||
const msSinceOutput = Date.now() - this.lastOutputTime;
|
'completion-confirm',
|
||||||
if (msSinceOutput >= this.config.completionConfirmMs) {
|
this.config.completionConfirmMs,
|
||||||
this.log(`Silence confirmed after completion message (${msSinceOutput}ms)`);
|
() => {
|
||||||
this.tryStartAiCheck('completion + silence');
|
this.completionConfirmTimer = null;
|
||||||
} else {
|
const msSinceOutput = Date.now() - this.lastOutputTime;
|
||||||
// Output received during wait, stay in confirming state and re-check
|
if (msSinceOutput >= this.config.completionConfirmMs) {
|
||||||
this.log(`Output received during confirmation, resetting timer`);
|
this.logAction('detection', `Silence confirmed: ${Math.round(msSinceOutput / 1000)}s`);
|
||||||
this.startCompletionConfirmTimer();
|
this.tryStartAiCheck('completion + silence');
|
||||||
}
|
} else {
|
||||||
}, this.config.completionConfirmMs);
|
// 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.
|
* This ensures Claude has finished processing before we send the next command.
|
||||||
*/
|
*/
|
||||||
private startStepConfirmTimer(step: 'update' | 'init' | 'kickstart'): void {
|
private startStepConfirmTimer(step: 'update' | 'init' | 'kickstart'): void {
|
||||||
// Clear any existing step confirm timer
|
this.cancelTrackedTimer('step-confirm', this.stepConfirmTimer, 'restarting');
|
||||||
if (this.stepConfirmTimer) {
|
this.stepConfirmTimer = null;
|
||||||
clearTimeout(this.stepConfirmTimer);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.log(`Step '${step}' completion detected, waiting ${this.config.completionConfirmMs}ms for silence...`);
|
this.stepConfirmTimer = this.startTrackedTimer(
|
||||||
|
'step-confirm',
|
||||||
this.stepConfirmTimer = setTimeout(() => {
|
this.config.completionConfirmMs,
|
||||||
const msSinceOutput = Date.now() - this.lastOutputTime;
|
() => {
|
||||||
|
|
||||||
if (msSinceOutput >= this.config.completionConfirmMs) {
|
|
||||||
this.log(`Step '${step}' confirmed: ${msSinceOutput}ms silence`);
|
|
||||||
this.stepConfirmTimer = null;
|
this.stepConfirmTimer = null;
|
||||||
|
const msSinceOutput = Date.now() - this.lastOutputTime;
|
||||||
|
|
||||||
// Proceed with the step completion
|
if (msSinceOutput >= this.config.completionConfirmMs) {
|
||||||
switch (step) {
|
this.logAction('step', `${step} confirmed after ${Math.round(msSinceOutput / 1000)}s silence`);
|
||||||
case 'update':
|
|
||||||
this.checkUpdateComplete();
|
// Proceed with the step completion
|
||||||
break;
|
switch (step) {
|
||||||
case 'init':
|
case 'update':
|
||||||
this.checkInitComplete();
|
this.checkUpdateComplete();
|
||||||
break;
|
break;
|
||||||
case 'kickstart':
|
case 'init':
|
||||||
this.checkKickstartComplete();
|
this.checkInitComplete();
|
||||||
break;
|
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
|
`confirming ${step} completion`
|
||||||
this.log(`Output during step confirmation, resetting timer`);
|
);
|
||||||
this.startStepConfirmTimer(step);
|
|
||||||
}
|
|
||||||
}, this.config.completionConfirmMs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1786,15 +2022,21 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.setState('sending_update');
|
this.setState('sending_update');
|
||||||
this.terminalBuffer.clear(); // Clear buffer for fresh detection
|
this.terminalBuffer.clear(); // Clear buffer for fresh detection
|
||||||
|
|
||||||
this.stepTimer = setTimeout(() => {
|
this.stepTimer = this.startTrackedTimer(
|
||||||
const input = this.config.updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI
|
'step-delay',
|
||||||
this.log(`Sending update prompt: "${this.config.updatePrompt}"`);
|
this.config.interStepDelayMs,
|
||||||
this.session.writeViaScreen(input);
|
() => {
|
||||||
this.emit('stepSent', 'update', this.config.updatePrompt);
|
this.stepTimer = null;
|
||||||
this.setState('waiting_update');
|
const input = this.config.updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI
|
||||||
this.promptDetected = false;
|
this.logAction('command', `Sending: "${this.config.updatePrompt.substring(0, 50)}..."`);
|
||||||
this.workingDetected = false;
|
this.session.writeViaScreen(input);
|
||||||
}, this.config.interStepDelayMs);
|
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.setState('sending_clear');
|
||||||
this.terminalBuffer.clear();
|
this.terminalBuffer.clear();
|
||||||
|
|
||||||
this.stepTimer = setTimeout(() => {
|
this.stepTimer = this.startTrackedTimer(
|
||||||
this.log('Sending /clear');
|
'step-delay',
|
||||||
this.session.writeViaScreen('/clear\r'); // \r triggers Enter in Ink/Claude CLI
|
this.config.interStepDelayMs,
|
||||||
this.emit('stepSent', 'clear', '/clear');
|
() => {
|
||||||
this.setState('waiting_clear');
|
this.stepTimer = null;
|
||||||
this.promptDetected = false;
|
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
|
// Start fallback timer - if no prompt detected after 10s, proceed to /init anyway
|
||||||
this.clearFallbackTimer = setTimeout(() => {
|
this.clearFallbackTimer = this.startTrackedTimer(
|
||||||
if (this._state === 'waiting_clear') {
|
'clear-fallback',
|
||||||
this.log('/clear fallback: no prompt detected after 10s, proceeding to /init');
|
RespawnController.CLEAR_FALLBACK_TIMEOUT_MS,
|
||||||
this.emit('stepCompleted', 'clear');
|
() => {
|
||||||
if (this.config.sendInit) {
|
this.clearFallbackTimer = null;
|
||||||
this.sendInit();
|
if (this._state === 'waiting_clear') {
|
||||||
} else {
|
this.logAction('step', '/clear fallback: proceeding to /init');
|
||||||
this.completeCycle();
|
this.emit('stepCompleted', 'clear');
|
||||||
}
|
if (this.config.sendInit) {
|
||||||
}
|
this.sendInit();
|
||||||
}, RespawnController.CLEAR_FALLBACK_TIMEOUT_MS);
|
} else {
|
||||||
}, this.config.interStepDelayMs);
|
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.setState('sending_init');
|
||||||
this.terminalBuffer.clear();
|
this.terminalBuffer.clear();
|
||||||
|
|
||||||
this.stepTimer = setTimeout(() => {
|
this.stepTimer = this.startTrackedTimer(
|
||||||
this.log('Sending /init');
|
'step-delay',
|
||||||
this.session.writeViaScreen('/init\r'); // \r triggers Enter in Ink/Claude CLI
|
this.config.interStepDelayMs,
|
||||||
this.emit('stepSent', 'init', '/init');
|
() => {
|
||||||
this.setState('waiting_init');
|
this.stepTimer = null;
|
||||||
this.promptDetected = false;
|
this.logAction('command', 'Sending: /init');
|
||||||
this.workingDetected = false;
|
this.session.writeViaScreen('/init\r'); // \r triggers Enter in Ink/Claude CLI
|
||||||
}, this.config.interStepDelayMs);
|
this.emit('stepSent', 'init', '/init');
|
||||||
|
this.setState('waiting_init');
|
||||||
|
this.promptDetected = false;
|
||||||
|
this.workingDetected = false;
|
||||||
|
},
|
||||||
|
'delay before /init'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+171
-1
@@ -361,6 +361,9 @@ class ClaudemanApp {
|
|||||||
this.activeSessionId = null;
|
this.activeSessionId = null;
|
||||||
this.respawnStatus = {};
|
this.respawnStatus = {};
|
||||||
this.respawnTimers = {}; // Track timed respawn timers
|
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.terminalBuffers = new Map(); // Store terminal content per session
|
||||||
this.editingSessionId = null; // Session being edited in options modal
|
this.editingSessionId = null; // Session being edited in options modal
|
||||||
this.pendingCloseSessionId = null; // Session pending close confirmation
|
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) => {
|
this.eventSource.addEventListener('respawn:timerStarted', (e) => {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
this.respawnTimers[data.sessionId] = {
|
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
|
// Auto-clear event
|
||||||
this.eventSource.addEventListener('session:autoClear', (e) => {
|
this.eventSource.addEventListener('session:autoClear', (e) => {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
@@ -1468,8 +1525,15 @@ class ClaudemanApp {
|
|||||||
this.showRespawnBanner();
|
this.showRespawnBanner();
|
||||||
this.updateRespawnBanner(this.respawnStatus[sessionId].state);
|
this.updateRespawnBanner(this.respawnStatus[sessionId].state);
|
||||||
document.getElementById('respawnCycleCount').textContent = this.respawnStatus[sessionId].cycleCount || 0;
|
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 {
|
} else {
|
||||||
this.hideRespawnBanner();
|
this.hideRespawnBanner();
|
||||||
|
this.stopCountdownInterval();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update task panel if open
|
// Update task panel if open
|
||||||
@@ -1501,6 +1565,7 @@ class ClaudemanApp {
|
|||||||
this.sessions.delete(sessionId);
|
this.sessions.delete(sessionId);
|
||||||
this.terminalBuffers.delete(sessionId);
|
this.terminalBuffers.delete(sessionId);
|
||||||
this.ralphStates.delete(sessionId);
|
this.ralphStates.delete(sessionId);
|
||||||
|
this.clearCountdownTimers(sessionId);
|
||||||
|
|
||||||
if (this.activeSessionId === sessionId) {
|
if (this.activeSessionId === sessionId) {
|
||||||
this.activeSessionId = null;
|
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() {
|
async stopRespawn() {
|
||||||
if (!this.activeSessionId) return;
|
if (!this.activeSessionId) return;
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/sessions/${this.activeSessionId}/respawn/stop`, { method: 'POST' });
|
await fetch(`/api/sessions/${this.activeSessionId}/respawn/stop`, { method: 'POST' });
|
||||||
delete this.respawnTimers[this.activeSessionId];
|
delete this.respawnTimers[this.activeSessionId];
|
||||||
|
this.clearCountdownTimers(this.activeSessionId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.showToast('Failed to stop respawn', 'error');
|
this.showToast('Failed to stop respawn', 'error');
|
||||||
}
|
}
|
||||||
@@ -2042,6 +2208,9 @@ class ClaudemanApp {
|
|||||||
this.terminalBuffers.clear();
|
this.terminalBuffers.clear();
|
||||||
this.activeSessionId = null;
|
this.activeSessionId = null;
|
||||||
this.respawnStatus = {};
|
this.respawnStatus = {};
|
||||||
|
this.respawnCountdownTimers = {};
|
||||||
|
this.respawnActionLogs = {};
|
||||||
|
this.stopCountdownInterval();
|
||||||
this.hideRespawnBanner();
|
this.hideRespawnBanner();
|
||||||
this.renderSessionTabs();
|
this.renderSessionTabs();
|
||||||
this.terminal.clear();
|
this.terminal.clear();
|
||||||
@@ -2397,6 +2566,7 @@ class ClaudemanApp {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
respawnConfig: {
|
respawnConfig: {
|
||||||
|
enabled: true, // Fix: ensure enabled is set so pre-saved configs with enabled: false get overridden
|
||||||
updatePrompt,
|
updatePrompt,
|
||||||
sendClear,
|
sendClear,
|
||||||
sendInit,
|
sendInit,
|
||||||
|
|||||||
+23
-13
@@ -67,19 +67,29 @@
|
|||||||
|
|
||||||
<!-- Respawn Banner -->
|
<!-- Respawn Banner -->
|
||||||
<div class="respawn-banner" id="respawnBanner" style="display: none;">
|
<div class="respawn-banner" id="respawnBanner" style="display: none;">
|
||||||
<span class="respawn-indicator">↻</span>
|
<div class="respawn-banner-row">
|
||||||
<span class="respawn-state" id="respawnState">Watching</span>
|
<span class="respawn-indicator">↻</span>
|
||||||
<span class="respawn-detection" id="respawnDetection">
|
<span class="respawn-state" id="respawnState">Watching</span>
|
||||||
<span class="detection-status" id="detectionStatus"></span>
|
<span class="respawn-detection" id="respawnDetection">
|
||||||
<span class="detection-waiting" id="detectionWaiting"></span>
|
<span class="detection-status" id="detectionStatus"></span>
|
||||||
<span class="detection-confidence" id="detectionConfidence"></span>
|
<span class="detection-waiting" id="detectionWaiting"></span>
|
||||||
<span class="detection-ai-check" id="detectionAiCheck" style="display: none;"></span>
|
<span class="detection-confidence" id="detectionConfidence"></span>
|
||||||
</span>
|
<span class="detection-ai-check" id="detectionAiCheck" style="display: none;"></span>
|
||||||
<span class="respawn-cycles">Cycle <span id="respawnCycleCount">0</span></span>
|
</span>
|
||||||
<span class="respawn-timer" id="respawnTimer" style="display: none;"></span>
|
<span class="respawn-cycles">Cycle <span id="respawnCycleCount">0</span></span>
|
||||||
<span class="respawn-tokens" id="respawnTokens" style="display: none;"></span>
|
<span class="respawn-timer" id="respawnTimer" style="display: none;"></span>
|
||||||
<span class="respawn-step" id="respawnStep"></span>
|
<span class="respawn-tokens" id="respawnTokens" style="display: none;"></span>
|
||||||
<button class="btn-icon-only" onclick="app.stopRespawn()" title="Stop Respawn">■</button>
|
<span class="respawn-step" id="respawnStep"></span>
|
||||||
|
<button class="btn-icon-only" onclick="app.stopRespawn()" title="Stop Respawn">■</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>
|
</div>
|
||||||
|
|
||||||
<!-- Ralph / Todo Tracker Panel -->
|
<!-- Ralph / Todo Tracker Panel -->
|
||||||
|
|||||||
@@ -454,8 +454,7 @@ body {
|
|||||||
/* Respawn Banner - Compact */
|
/* Respawn Banner - Compact */
|
||||||
.respawn-banner {
|
.respawn-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.35rem 1rem;
|
padding: 0.35rem 1rem;
|
||||||
background: linear-gradient(90deg, #1e3a1e, #132513);
|
background: linear-gradient(90deg, #1e3a1e, #132513);
|
||||||
border-bottom: 1px solid rgba(34, 197, 94, 0.3);
|
border-bottom: 1px solid rgba(34, 197, 94, 0.3);
|
||||||
@@ -463,6 +462,104 @@ body {
|
|||||||
flex-shrink: 0;
|
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 {
|
.respawn-indicator {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
animation: spin 2s linear infinite;
|
animation: spin 2s linear infinite;
|
||||||
|
|||||||
@@ -1866,6 +1866,23 @@ export class WebServer extends EventEmitter {
|
|||||||
this.broadcast('respawn:planCheckFailed', { sessionId, error });
|
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) => {
|
controller.on('log', (message: string) => {
|
||||||
this.broadcast('respawn:log', { sessionId, message });
|
this.broadcast('respawn:log', { sessionId, message });
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user