From d711ec8b3a6c56ce06be4c7bdc3789f536353ebb Mon Sep 17 00:00:00 2001 From: arkon Date: Mon, 19 Jan 2026 13:08:53 +0100 Subject: [PATCH] feat: add kickstart prompt and auto-compact context options Kickstart Prompt: - Remove Idle Timeout setting from respawn UI (uses 10s default) - Add Kickstart Prompt field sent when /init doesn't trigger work - New states: monitoring_init, sending_kickstart, waiting_kickstart - Monitors 3s after /init to detect if Claude started working Auto-Compact Context: - Add auto-compact at 110k tokens (sends /compact with optional prompt) - Preserves summarized context unlike /clear which wipes everything - Optional focus prompt to guide what compact should preserve - Blocks auto-clear while compacting to prevent conflicts Also updates auto-clear default threshold from 100k to 140k tokens. Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 15 +++++-- src/respawn-controller.ts | 90 +++++++++++++++++++++++++++++++++++---- src/session.ts | 78 +++++++++++++++++++++++++++++++-- src/types.ts | 2 + src/web/public/app.js | 35 +++++++++++++-- src/web/public/index.html | 33 ++++++++++---- src/web/public/styles.css | 3 ++ src/web/server.ts | 28 ++++++++++++ 8 files changed, 255 insertions(+), 29 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 38e624fb..b3660738 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,9 +73,9 @@ src/ ### Key Components -- **Session** (`src/session.ts`): Wraps Claude CLI as PTY subprocess. Two modes: `runPrompt(prompt)` for one-shot, `startInteractive()` for persistent terminal. Emits events: `output`, `terminal`, `message`, `completion`, `exit`, `idle`, `working`, `autoClear`, `clearTerminal`. +- **Session** (`src/session.ts`): Wraps Claude CLI as PTY subprocess. Two modes: `runPrompt(prompt)` for one-shot, `startInteractive()` for persistent terminal. Emits events: `output`, `terminal`, `message`, `completion`, `exit`, `idle`, `working`, `autoClear`, `autoCompact`, `clearTerminal`. -- **RespawnController** (`src/respawn-controller.ts`): State machine that keeps sessions productive. Detects idle → sends update prompt → optionally `/clear` → optionally `/init` → repeats. State flow: `WATCHING → SENDING_UPDATE → WAITING_UPDATE → SENDING_CLEAR → WAITING_CLEAR → SENDING_INIT → WAITING_INIT → WATCHING` +- **RespawnController** (`src/respawn-controller.ts`): State machine that keeps sessions productive. Detects idle → sends update prompt → optionally `/clear` → optionally `/init` → optionally kickstart prompt → repeats. State flow: `WATCHING → SENDING_UPDATE → WAITING_UPDATE → SENDING_CLEAR → WAITING_CLEAR → SENDING_INIT → WAITING_INIT → MONITORING_INIT → (optional) SENDING_KICKSTART → WAITING_KICKSTART → WATCHING` - **ScreenManager** (`src/screen-manager.ts`): Wraps sessions in GNU screen for persistence across server restarts. On startup, reconciles with `screen -ls` to restore sessions and discovers unknown "ghost" screens. Uses 4-strategy kill process to prevent orphaned claude processes. @@ -140,6 +140,15 @@ pty.spawn('claude', ['--dangerously-skip-permissions'], { ... }) - **One-shot mode**: Uses `--output-format stream-json` for detailed token usage from JSON - **Interactive mode**: Parses tokens from Claude's status line (e.g., "123.4k tokens"), estimates 60/40 input/output split +### Auto-Compact & Auto-Clear + +Sessions support automatic context management when token thresholds are reached: + +- **Auto-Compact** (default: 110k tokens): Sends `/compact` command with optional prompt to summarize context +- **Auto-Clear** (default: 140k tokens): Sends `/clear` to reset context entirely + +Both wait for Claude to be idle before executing. Auto-compact runs first if both are enabled. Configured via `session.setAutoCompact(enabled, threshold?, prompt?)` and `session.setAutoClear(enabled, threshold?)`. + ### Terminal Display Fix (Tab Switch & New Session) When switching tabs or creating new sessions, terminal may be rendered at wrong size. Fix sequence: @@ -154,7 +163,7 @@ Uses `pendingCtrlL` Set to track sessions needing the fix. Waits for `session:id All events broadcast to `/api/events` with format: `{ type: string, sessionId?: string, data: any }`. -Event prefixes: `session:`, `task:`, `respawn:`, `scheduled:`, `case:`, `init`. Key events: `session:idle`, `session:working`, `session:terminal`, `session:clearTerminal`, `session:completion`. +Event prefixes: `session:`, `task:`, `respawn:`, `scheduled:`, `case:`, `screen:`, `init`. Key events: `session:idle`, `session:working`, `session:terminal`, `session:clearTerminal`, `session:completion`, `session:autoClear`, `session:autoCompact`. ### Frontend (app.js) diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index 758bfd61..75254c5c 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -17,17 +17,20 @@ const READY_INDICATOR = '↵ send'; * Respawn sequence states * * The controller cycles through these states: - * WATCHING → SENDING_UPDATE → WAITING_UPDATE → SENDING_CLEAR → WAITING_CLEAR → SENDING_INIT → WAITING_INIT → WATCHING + * WATCHING → SENDING_UPDATE → WAITING_UPDATE → SENDING_CLEAR → WAITING_CLEAR → SENDING_INIT → WAITING_INIT → MONITORING_INIT → (maybe SENDING_KICKSTART → WAITING_KICKSTART) → 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 + | '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 + | 'monitoring_init' // Monitoring if /init triggered work + | 'sending_kickstart' // About to send kickstart prompt + | 'waiting_kickstart' // Waiting for kickstart to complete + | 'stopped'; // Controller stopped export interface RespawnConfig { /** How long to wait after seeing prompt before considering truly idle (ms) */ @@ -42,6 +45,8 @@ export interface RespawnConfig { sendClear: boolean; /** Whether to send /init after /clear */ sendInit: boolean; + /** Optional prompt to send if /init doesn't trigger work */ + kickstartPrompt?: string; } export interface RespawnEvents { @@ -210,6 +215,13 @@ export class RespawnController extends EventEmitter { this.promptDetected = false; this.lastActivityTime = Date.now(); this.clearIdleTimer(); + + // If we're monitoring init and work started, go to watching (no kickstart needed) + if (this._state === 'monitoring_init') { + this.log('/init triggered work, skipping kickstart'); + this.emit('stepCompleted', 'init'); + this.completeCycle(); + } return; } @@ -235,6 +247,12 @@ export class RespawnController extends EventEmitter { case 'waiting_init': this.checkInitComplete(); break; + case 'monitoring_init': + this.checkMonitoringInitIdle(); + break; + case 'waiting_kickstart': + this.checkKickstartComplete(); + break; } return; } @@ -287,7 +305,61 @@ export class RespawnController extends EventEmitter { private checkInitComplete(): void { this.clearIdleTimer(); this.log('/init completed (ready indicator)'); + + // If kickstart prompt is configured, monitor to see if /init triggered work + if (this.config.kickstartPrompt) { + this.startMonitoringInit(); + } else { + this.emit('stepCompleted', 'init'); + this.completeCycle(); + } + } + + private startMonitoringInit(): void { + this.setState('monitoring_init'); + this.terminalBuffer = ''; + this.workingDetected = false; + this.log('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 + } + + private checkMonitoringInitIdle(): void { + this.clearIdleTimer(); + if (this.stepTimer) { + clearTimeout(this.stepTimer); + this.stepTimer = null; + } + this.log('/init did not trigger work, sending kickstart prompt'); this.emit('stepCompleted', 'init'); + this.sendKickstart(); + } + + private sendKickstart(): void { + this.setState('sending_kickstart'); + this.terminalBuffer = ''; + + this.stepTimer = setTimeout(() => { + const prompt = this.config.kickstartPrompt!; + this.log(`Sending kickstart prompt: "${prompt}"`); + this.session.write(prompt + '\n'); + this.emit('stepSent', 'kickstart', prompt); + this.setState('waiting_kickstart'); + this.promptDetected = false; + this.workingDetected = false; + }, this.config.interStepDelayMs); + } + + private checkKickstartComplete(): void { + this.clearIdleTimer(); + this.log('Kickstart completed (ready indicator)'); + this.emit('stepCompleted', 'kickstart'); this.completeCycle(); } diff --git a/src/session.ts b/src/session.ts index a14ce761..9b9388b6 100644 --- a/src/session.ts +++ b/src/session.ts @@ -62,6 +62,8 @@ export interface SessionEvents { taskFailed: (task: BackgroundTask, error: string) => void; // Auto-clear event autoClear: (data: { tokens: number; threshold: number }) => void; + // Auto-compact event + autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void; } export type SessionMode = 'claude' | 'shell'; @@ -97,10 +99,16 @@ export class Session extends EventEmitter { // Token tracking for auto-clear private _totalInputTokens: number = 0; private _totalOutputTokens: number = 0; - private _autoClearThreshold: number = 100000; // Default 100k tokens + private _autoClearThreshold: number = 140000; // Default 140k tokens private _autoClearEnabled: boolean = false; private _isClearing: boolean = false; // Prevent recursive clearing + // Auto-compact settings + private _autoCompactThreshold: number = 110000; // Default 110k tokens (lower than clear) + private _autoCompactEnabled: boolean = false; + private _autoCompactPrompt: string = ''; // Optional prompt for compact + private _isCompacting: boolean = false; // Prevent recursive compacting + // Screen session support private _screenManager: ScreenManager | null = null; private _screenSession: ScreenSession | null = null; @@ -235,6 +243,28 @@ export class Session extends EventEmitter { } } + get autoCompactThreshold(): number { + return this._autoCompactThreshold; + } + + get autoCompactEnabled(): boolean { + return this._autoCompactEnabled; + } + + get autoCompactPrompt(): string { + return this._autoCompactPrompt; + } + + setAutoCompact(enabled: boolean, threshold?: number, prompt?: string): void { + this._autoCompactEnabled = enabled; + if (threshold !== undefined) { + this._autoCompactThreshold = threshold; + } + if (prompt !== undefined) { + this._autoCompactPrompt = prompt; + } + } + isIdle(): boolean { return this._status === 'idle'; } @@ -694,7 +724,8 @@ export class Session extends EventEmitter { this._totalInputTokens += msg.message.usage.input_tokens || 0; this._totalOutputTokens += msg.message.usage.output_tokens || 0; - // Check if we should auto-clear + // Check if we should auto-compact or auto-clear + this.checkAutoCompact(); this.checkAutoClear(); } } @@ -748,15 +779,54 @@ export class Session extends EventEmitter { this._totalInputTokens += Math.round(delta * 0.6); this._totalOutputTokens += Math.round(delta * 0.4); - // Check if we should auto-clear + // Check if we should auto-compact or auto-clear + this.checkAutoCompact(); this.checkAutoClear(); } } } + // Check if we should auto-compact based on token threshold + private checkAutoCompact(): void { + if (!this._autoCompactEnabled || this._isCompacting || this._isClearing) return; + + const totalTokens = this._totalInputTokens + this._totalOutputTokens; + if (totalTokens >= this._autoCompactThreshold) { + this._isCompacting = true; + console.log(`[Session] Auto-compact triggered: ${totalTokens} tokens >= ${this._autoCompactThreshold} threshold`); + + // Wait for Claude to be idle before compacting + const checkAndCompact = () => { + if (!this._isWorking) { + // Send /compact command with optional prompt + const compactCmd = this._autoCompactPrompt + ? `/compact ${this._autoCompactPrompt}\n` + : '/compact\n'; + this.write(compactCmd); + this.emit('autoCompact', { + tokens: totalTokens, + threshold: this._autoCompactThreshold, + prompt: this._autoCompactPrompt || undefined + }); + + // Wait a moment then re-enable (longer than clear since compact takes time) + setTimeout(() => { + this._isCompacting = false; + }, 10000); + } else { + // Check again in 2 seconds + setTimeout(checkAndCompact, 2000); + } + }; + + // Start checking after a short delay + setTimeout(checkAndCompact, 1000); + } + } + // Check if we should auto-clear based on token threshold private checkAutoClear(): void { - if (!this._autoClearEnabled || this._isClearing) return; + if (!this._autoClearEnabled || this._isClearing || this._isCompacting) return; const totalTokens = this._totalInputTokens + this._totalOutputTokens; if (totalTokens >= this._autoClearThreshold) { diff --git a/src/types.ts b/src/types.ts index fc5d2e18..e6d6ec31 100644 --- a/src/types.ts +++ b/src/types.ts @@ -74,6 +74,8 @@ export interface RespawnConfig { sendClear: boolean; /** Whether to send /init after /clear */ sendInit: boolean; + /** Optional prompt to send if /init doesn't trigger work */ + kickstartPrompt?: string; } export interface AppConfig { diff --git a/src/web/public/app.js b/src/web/public/app.js index 044016f5..a73bd7b6 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1264,22 +1264,32 @@ class ClaudemanApp { // Get respawn config from modal inputs getModalRespawnConfig() { const updatePrompt = document.getElementById('modalRespawnPrompt').value; - const idleTimeout = parseInt(document.getElementById('modalRespawnIdleTimeout').value) || 5; const sendClear = document.getElementById('modalRespawnSendClear').checked; const sendInit = document.getElementById('modalRespawnSendInit').checked; + const kickstartPrompt = document.getElementById('modalRespawnKickstart').value.trim() || undefined; const durationStr = document.getElementById('modalRespawnDuration').value; const durationMinutes = durationStr ? parseInt(durationStr) : null; + + // Auto-compact settings + const autoCompactEnabled = document.getElementById('modalAutoCompactEnabled').checked; + const autoCompactThreshold = parseInt(document.getElementById('modalAutoCompactThreshold').value) || 110000; + const autoCompactPrompt = document.getElementById('modalAutoCompactPrompt').value.trim() || undefined; + + // Auto-clear settings const autoClearEnabled = document.getElementById('modalAutoClearEnabled').checked; - const autoClearThreshold = parseInt(document.getElementById('modalAutoClearThreshold').value) || 100000; + const autoClearThreshold = parseInt(document.getElementById('modalAutoClearThreshold').value) || 140000; return { respawnConfig: { updatePrompt, - idleTimeoutMs: idleTimeout * 1000, sendClear, sendInit, + kickstartPrompt, }, durationMinutes, + autoCompactEnabled, + autoCompactThreshold, + autoCompactPrompt, autoClearEnabled, autoClearThreshold }; @@ -1291,7 +1301,15 @@ class ClaudemanApp { return; } - const { respawnConfig, durationMinutes, autoClearEnabled, autoClearThreshold } = this.getModalRespawnConfig(); + const { + respawnConfig, + durationMinutes, + autoCompactEnabled, + autoCompactThreshold, + autoCompactPrompt, + autoClearEnabled, + autoClearThreshold + } = this.getModalRespawnConfig(); try { // Enable respawn on the session @@ -1303,6 +1321,15 @@ class ClaudemanApp { const data = await res.json(); if (data.error) throw new Error(data.error); + // Set auto-compact if enabled + if (autoCompactEnabled) { + await fetch(`/api/sessions/${this.editingSessionId}/auto-compact`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, threshold: autoCompactThreshold, prompt: autoCompactPrompt }) + }); + } + // Set auto-clear if enabled if (autoClearEnabled) { await fetch(`/api/sessions/${this.editingSessionId}/auto-clear`, { diff --git a/src/web/public/index.html b/src/web/public/index.html index 59f032b5..61b0a8d4 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -204,14 +204,6 @@ -
- -
- - sec -
-
-
@@ -235,6 +227,28 @@
+
+ + + Sent only when /init completes but Claude stays idle +
+ +
+ +
+ +
+ + tokens +
+
+ + Summarizes context while preserving key information +
+
@@ -243,10 +257,11 @@ Enable at
- + tokens
+ Completely resets context (use higher threshold than compact)
diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 883b2516..ea5d9aa6 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -21,6 +21,9 @@ * { box-sizing: border-box; margin: 0; padding: 0; } +/* Utility classes */ +.mt-sm { margin-top: 0.5rem; } + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg-dark); diff --git a/src/web/server.ts b/src/web/server.ts index 9fea3d08..9be74215 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -515,6 +515,29 @@ export class WebServer extends EventEmitter { }; }); + // Set auto-compact on a session + this.app.post('/api/sessions/:id/auto-compact', async (req) => { + const { id } = req.params as { id: string }; + const body = req.body as { enabled: boolean; threshold?: number; prompt?: string }; + const session = this.sessions.get(id); + + if (!session) { + return { error: 'Session not found' }; + } + + session.setAutoCompact(body.enabled, body.threshold, body.prompt); + this.broadcast('session:updated', session.toDetailedState()); + + return { + success: true, + autoCompact: { + enabled: session.autoCompactEnabled, + threshold: session.autoCompactThreshold, + prompt: session.autoCompactPrompt, + }, + }; + }); + // Quick run (create session, run prompt, return result, then cleanup) this.app.post('/api/run', async (req) => { // Prevent unbounded session creation @@ -912,6 +935,11 @@ export class WebServer extends EventEmitter { this.broadcast('session:autoClear', { sessionId: session.id, ...data }); this.broadcast('session:updated', session.toDetailedState()); }); + + session.on('autoCompact', (data: { tokens: number; threshold: number; prompt?: string }) => { + this.broadcast('session:autoCompact', { sessionId: session.id, ...data }); + this.broadcast('session:updated', session.toDetailedState()); + }); } private setupRespawnListeners(sessionId: string, controller: RespawnController): void {