mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
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>
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user