Files
Codeman/src/respawn-controller.ts
T
arkonandClaude Opus 4.5 675893111b feat: add RespawnController for automatic Claude Code session respawning
Implements an autonomous respawn loop that keeps Claude Code productive:
- Detects idle state by monitoring PTY for prompt character + timeout
- Sends configurable update prompt when idle (default: "update all the docs and CLAUDE.md")
- Executes /clear and /init to reset session
- Cycles automatically

New features:
- RespawnController class with state machine (watching → update → clear → init)
- REST API endpoints for respawn control (/api/sessions/:id/respawn/*)
- Web UI controls: toggle, prompt config, idle timeout settings
- SSE events for real-time respawn status updates
- Full CLAUDE.md template with Ralph Loop instructions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 07:31:19 +01:00

417 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { EventEmitter } from 'node:events';
import { Session } from './session.js';
/**
* Respawn sequence states
*
* The controller cycles through these states:
* WATCHING → SENDING_UPDATE → WAITING_UPDATE → SENDING_CLEAR → WAITING_CLEAR → SENDING_INIT → WAITING_INIT → WATCHING
*/
export type RespawnState =
| 'watching' // Watching for idle, ready to start respawn sequence
| 'sending_update' // About to send the update docs prompt
| 'waiting_update' // Waiting for update to complete
| 'sending_clear' // About to send /clear
| 'waiting_clear' // Waiting for clear to complete
| 'sending_init' // About to send /init
| 'waiting_init' // Waiting for init to complete
| 'stopped'; // Controller stopped
export interface RespawnConfig {
/** How long to wait after seeing prompt before considering truly idle (ms) */
idleTimeoutMs: number;
/** The prompt to send for updating docs */
updatePrompt: string;
/** Delay between sending steps (ms) */
interStepDelayMs: number;
/** Whether to enable respawn loop */
enabled: boolean;
}
export interface RespawnEvents {
stateChanged: (state: RespawnState, prevState: RespawnState) => void;
respawnCycleStarted: (cycleNumber: number) => void;
respawnCycleCompleted: (cycleNumber: number) => void;
stepSent: (step: string, input: string) => void;
stepCompleted: (step: string) => void;
error: (error: Error) => void;
log: (message: string) => void;
}
const DEFAULT_CONFIG: RespawnConfig = {
idleTimeoutMs: 5000, // 5 seconds of no activity after prompt
updatePrompt: 'update all the docs and CLAUDE.md',
interStepDelayMs: 1000, // 1 second between steps
enabled: true,
};
/**
* RespawnController manages automatic respawning of Claude Code sessions
*
* When Claude finishes working (detected by idle prompt), it:
* 1. Sends an update docs prompt
* 2. Waits for completion
* 3. Sends /clear
* 4. Sends /init
* 5. Repeats
*/
export class RespawnController extends EventEmitter {
private session: Session;
private config: RespawnConfig;
private _state: RespawnState = 'stopped';
private idleTimer: NodeJS.Timeout | null = null;
private stepTimer: NodeJS.Timeout | null = null;
private cycleCount: number = 0;
private lastActivityTime: number = 0;
private terminalBuffer: string = '';
private promptDetected: boolean = false;
private workingDetected: boolean = false;
// Terminal patterns
private readonly PROMPT_PATTERNS = [
'❯', // Standard prompt
'\u276f', // Unicode variant
'⏵', // Claude Code prompt variant
'> ', // Fallback
'tokens', // The status line shows "X tokens" when at prompt
];
private readonly WORKING_PATTERNS = [
'Thinking', 'Writing', 'Reading', 'Running', 'Searching',
'Editing', 'Creating', 'Deleting', 'Analyzing', 'Executing',
'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏', // Spinner chars
];
private readonly CLEAR_COMPLETE_PATTERN = /conversation cleared|cleared|❯|⏵/i;
private readonly INIT_COMPLETE_PATTERN = /initialized|analyzing|❯|⏵|CLAUDE\.md/i;
constructor(session: Session, config: Partial<RespawnConfig> = {}) {
super();
this.session = session;
this.config = { ...DEFAULT_CONFIG, ...config };
}
get state(): RespawnState {
return this._state;
}
get currentCycle(): number {
return this.cycleCount;
}
get isRunning(): boolean {
return this._state !== 'stopped';
}
private setState(newState: RespawnState): void {
if (newState === this._state) return;
const prevState = this._state;
this._state = newState;
this.log(`State: ${prevState} → ${newState}`);
this.emit('stateChanged', newState, prevState);
}
private log(message: string): void {
const timestamp = new Date().toISOString();
this.emit('log', `[${timestamp}] [Respawn] ${message}`);
}
/**
* Start watching the session for idle state
*/
start(): void {
if (!this.config.enabled) {
this.log('Respawn is disabled');
return;
}
if (this._state !== 'stopped') {
this.log('Already running');
return;
}
this.log('Starting respawn controller');
this.setState('watching');
this.setupTerminalListener();
this.lastActivityTime = Date.now();
}
/**
* Stop the respawn controller
*/
stop(): void {
this.log('Stopping respawn controller');
this.clearTimers();
this.setState('stopped');
this.session.removeAllListeners('terminal');
}
/**
* Pause respawn (keeps listening but won't trigger)
*/
pause(): void {
this.log('Pausing respawn');
this.clearTimers();
// Stay in current state but clear timers
}
/**
* Resume respawn
*/
resume(): void {
this.log('Resuming respawn');
if (this._state === 'watching') {
this.checkIdleAndMaybeStart();
}
}
private setupTerminalListener(): void {
// Clear any existing listener
this.session.removeAllListeners('terminal');
this.session.on('terminal', (data: string) => {
this.handleTerminalData(data);
});
}
private handleTerminalData(data: string): void {
this.terminalBuffer += data;
// Keep buffer manageable (last 10KB)
if (this.terminalBuffer.length > 10240) {
this.terminalBuffer = this.terminalBuffer.slice(-5120);
}
// Filter out noise - only count meaningful data as activity
// Ignore: cursor movements, color codes alone, small whitespace-only data
const meaningfulData = data
.replace(/\x1b\[[0-9;]*[HJKmsu?lh]/g, '') // Remove ANSI escape sequences
.replace(/\s+/g, '') // Remove whitespace
.trim();
const isMeaningfulActivity = meaningfulData.length > 0;
// Detect working state
const isWorking = this.WORKING_PATTERNS.some(pattern => data.includes(pattern));
if (isWorking) {
this.workingDetected = true;
this.promptDetected = false;
this.lastActivityTime = Date.now();
this.clearIdleTimer();
return;
}
// Detect prompt (idle) state
const hasPrompt = this.PROMPT_PATTERNS.some(pattern => data.includes(pattern));
if (hasPrompt) {
const wasPromptDetected = this.promptDetected;
this.promptDetected = true;
this.workingDetected = false;
if (!wasPromptDetected) {
// First time seeing prompt (or after being cleared) - log it
this.lastActivityTime = Date.now();
this.log('Prompt detected');
// Only start idle timer in watching state
if (this._state === 'watching') {
this.startIdleTimer();
}
}
// In waiting_* states, checkXxxComplete will handle the state transition
} else if (isMeaningfulActivity) {
// Meaningful activity that's not a prompt or working indicator
this.lastActivityTime = Date.now();
if (this.promptDetected && this._state === 'watching') {
// Still at prompt but got some other data - restart timer
this.startIdleTimer();
}
}
// Handle state-specific terminal data
switch (this._state) {
case 'waiting_update':
this.checkUpdateComplete(data);
break;
case 'waiting_clear':
this.checkClearComplete(data);
break;
case 'waiting_init':
this.checkInitComplete(data);
break;
}
}
private startIdleTimer(): void {
this.clearIdleTimer();
this.idleTimer = setTimeout(() => {
// Double-check we're still idle and in watching state
if (this._state === 'watching' && this.promptDetected && !this.workingDetected) {
const timeSinceActivity = Date.now() - this.lastActivityTime;
this.log(`Idle timeout fired (${timeSinceActivity}ms since last activity)`);
this.onIdleDetected();
}
}, this.config.idleTimeoutMs);
}
private clearIdleTimer(): void {
if (this.idleTimer) {
clearTimeout(this.idleTimer);
this.idleTimer = null;
}
}
private clearTimers(): void {
this.clearIdleTimer();
if (this.stepTimer) {
clearTimeout(this.stepTimer);
this.stepTimer = null;
}
}
private onIdleDetected(): void {
if (this._state !== 'watching') {
return;
}
// Start the respawn cycle
this.cycleCount++;
this.log(`Starting respawn cycle #${this.cycleCount}`);
this.emit('respawnCycleStarted', this.cycleCount);
this.sendUpdateDocs();
}
private sendUpdateDocs(): void {
this.setState('sending_update');
this.terminalBuffer = ''; // Clear buffer for fresh detection
this.stepTimer = setTimeout(() => {
const input = this.config.updatePrompt + '\n';
this.log(`Sending update prompt: "${this.config.updatePrompt}"`);
this.session.write(input);
this.emit('stepSent', 'update', this.config.updatePrompt);
this.setState('waiting_update');
this.promptDetected = false;
this.workingDetected = false;
}, this.config.interStepDelayMs);
}
private checkUpdateComplete(data: string): void {
// Update is complete when we see the prompt again after working
if (this.promptDetected && !this.workingDetected) {
// Wait a bit more to make sure it's truly done
this.clearIdleTimer();
this.idleTimer = setTimeout(() => {
if (this.promptDetected && !this.workingDetected) {
this.log('Update docs completed');
this.emit('stepCompleted', 'update');
this.sendClear();
}
}, 3000); // 3 second verification
}
}
private sendClear(): void {
this.setState('sending_clear');
this.terminalBuffer = '';
this.stepTimer = setTimeout(() => {
this.log('Sending /clear');
this.session.write('/clear\n');
this.emit('stepSent', 'clear', '/clear');
this.setState('waiting_clear');
this.promptDetected = false;
}, this.config.interStepDelayMs);
}
private checkClearComplete(data: string): void {
// Clear is fast, just wait for prompt
if (this.CLEAR_COMPLETE_PATTERN.test(data) || this.promptDetected) {
this.clearIdleTimer();
this.idleTimer = setTimeout(() => {
this.log('/clear completed');
this.emit('stepCompleted', 'clear');
this.sendInit();
}, 1000); // 1 second for clear
}
}
private sendInit(): void {
this.setState('sending_init');
this.terminalBuffer = '';
this.stepTimer = setTimeout(() => {
this.log('Sending /init');
this.session.write('/init\n');
this.emit('stepSent', 'init', '/init');
this.setState('waiting_init');
this.promptDetected = false;
this.workingDetected = false;
}, this.config.interStepDelayMs);
}
private checkInitComplete(data: string): void {
// Init completes when we see the prompt after it finishes
if (this.promptDetected && !this.workingDetected) {
this.clearIdleTimer();
this.idleTimer = setTimeout(() => {
if (this.promptDetected && !this.workingDetected) {
this.log('/init completed');
this.emit('stepCompleted', 'init');
this.completeCycle();
}
}, 3000); // 3 second verification for init
}
}
private completeCycle(): void {
this.log(`Respawn cycle #${this.cycleCount} completed`);
this.emit('respawnCycleCompleted', this.cycleCount);
// Go back to watching state for next cycle
this.setState('watching');
this.terminalBuffer = '';
this.promptDetected = false;
this.workingDetected = false;
}
private checkIdleAndMaybeStart(): void {
// Check if already idle
const timeSinceActivity = Date.now() - this.lastActivityTime;
if (timeSinceActivity > this.config.idleTimeoutMs && this.promptDetected) {
this.onIdleDetected();
}
}
/**
* Update configuration
*/
updateConfig(config: Partial<RespawnConfig>): void {
this.config = { ...this.config, ...config };
this.log(`Config updated: ${JSON.stringify(config)}`);
}
/**
* Get current configuration
*/
getConfig(): RespawnConfig {
return { ...this.config };
}
/**
* Get status information
*/
getStatus() {
return {
state: this._state,
cycleCount: this.cycleCount,
lastActivityTime: this.lastActivityTime,
timeSinceActivity: Date.now() - this.lastActivityTime,
promptDetected: this.promptDetected,
workingDetected: this.workingDetected,
config: this.config,
};
}
}