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 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-19 13:08:53 +01:00
co-authored by Claude Opus 4.5
parent 0949b52830
commit d711ec8b3a
8 changed files with 255 additions and 29 deletions
+12 -3
View File
@@ -73,9 +73,9 @@ src/
### Key Components ### 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. - **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 - **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 - **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) ### Terminal Display Fix (Tab Switch & New Session)
When switching tabs or creating new sessions, terminal may be rendered at wrong size. Fix sequence: 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 }`. 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) ### Frontend (app.js)
+81 -9
View File
@@ -17,17 +17,20 @@ const READY_INDICATOR = '↵ send';
* Respawn sequence states * Respawn sequence states
* *
* The controller cycles through these 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 = export type RespawnState =
| 'watching' // Watching for idle, ready to start respawn sequence | 'watching' // Watching for idle, ready to start respawn sequence
| 'sending_update' // About to send the update docs prompt | 'sending_update' // About to send the update docs prompt
| 'waiting_update' // Waiting for update to complete | 'waiting_update' // Waiting for update to complete
| 'sending_clear' // About to send /clear | 'sending_clear' // About to send /clear
| 'waiting_clear' // Waiting for clear to complete | 'waiting_clear' // Waiting for clear to complete
| 'sending_init' // About to send /init | 'sending_init' // About to send /init
| 'waiting_init' // Waiting for init to complete | 'waiting_init' // Waiting for init to complete
| 'stopped'; // Controller stopped | '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 { export interface RespawnConfig {
/** How long to wait after seeing prompt before considering truly idle (ms) */ /** How long to wait after seeing prompt before considering truly idle (ms) */
@@ -42,6 +45,8 @@ export interface RespawnConfig {
sendClear: boolean; sendClear: boolean;
/** Whether to send /init after /clear */ /** Whether to send /init after /clear */
sendInit: boolean; sendInit: boolean;
/** Optional prompt to send if /init doesn't trigger work */
kickstartPrompt?: string;
} }
export interface RespawnEvents { export interface RespawnEvents {
@@ -210,6 +215,13 @@ export class RespawnController extends EventEmitter {
this.promptDetected = false; this.promptDetected = false;
this.lastActivityTime = Date.now(); this.lastActivityTime = Date.now();
this.clearIdleTimer(); 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; return;
} }
@@ -235,6 +247,12 @@ export class RespawnController extends EventEmitter {
case 'waiting_init': case 'waiting_init':
this.checkInitComplete(); this.checkInitComplete();
break; break;
case 'monitoring_init':
this.checkMonitoringInitIdle();
break;
case 'waiting_kickstart':
this.checkKickstartComplete();
break;
} }
return; return;
} }
@@ -287,7 +305,61 @@ export class RespawnController extends EventEmitter {
private checkInitComplete(): void { private checkInitComplete(): void {
this.clearIdleTimer(); this.clearIdleTimer();
this.log('/init completed (ready indicator)'); 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.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(); this.completeCycle();
} }
+74 -4
View File
@@ -62,6 +62,8 @@ export interface SessionEvents {
taskFailed: (task: BackgroundTask, error: string) => void; taskFailed: (task: BackgroundTask, error: string) => void;
// Auto-clear event // Auto-clear event
autoClear: (data: { tokens: number; threshold: number }) => void; autoClear: (data: { tokens: number; threshold: number }) => void;
// Auto-compact event
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void;
} }
export type SessionMode = 'claude' | 'shell'; export type SessionMode = 'claude' | 'shell';
@@ -97,10 +99,16 @@ export class Session extends EventEmitter {
// Token tracking for auto-clear // Token tracking for auto-clear
private _totalInputTokens: number = 0; private _totalInputTokens: number = 0;
private _totalOutputTokens: 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 _autoClearEnabled: boolean = false;
private _isClearing: boolean = false; // Prevent recursive clearing 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 // Screen session support
private _screenManager: ScreenManager | null = null; private _screenManager: ScreenManager | null = null;
private _screenSession: ScreenSession | 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 { isIdle(): boolean {
return this._status === 'idle'; return this._status === 'idle';
} }
@@ -694,7 +724,8 @@ export class Session extends EventEmitter {
this._totalInputTokens += msg.message.usage.input_tokens || 0; this._totalInputTokens += msg.message.usage.input_tokens || 0;
this._totalOutputTokens += msg.message.usage.output_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(); this.checkAutoClear();
} }
} }
@@ -748,15 +779,54 @@ export class Session extends EventEmitter {
this._totalInputTokens += Math.round(delta * 0.6); this._totalInputTokens += Math.round(delta * 0.6);
this._totalOutputTokens += Math.round(delta * 0.4); 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(); 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 // Check if we should auto-clear based on token threshold
private checkAutoClear(): void { private checkAutoClear(): void {
if (!this._autoClearEnabled || this._isClearing) return; if (!this._autoClearEnabled || this._isClearing || this._isCompacting) return;
const totalTokens = this._totalInputTokens + this._totalOutputTokens; const totalTokens = this._totalInputTokens + this._totalOutputTokens;
if (totalTokens >= this._autoClearThreshold) { if (totalTokens >= this._autoClearThreshold) {
+2
View File
@@ -74,6 +74,8 @@ export interface RespawnConfig {
sendClear: boolean; sendClear: boolean;
/** Whether to send /init after /clear */ /** Whether to send /init after /clear */
sendInit: boolean; sendInit: boolean;
/** Optional prompt to send if /init doesn't trigger work */
kickstartPrompt?: string;
} }
export interface AppConfig { export interface AppConfig {
+31 -4
View File
@@ -1264,22 +1264,32 @@ class ClaudemanApp {
// Get respawn config from modal inputs // Get respawn config from modal inputs
getModalRespawnConfig() { getModalRespawnConfig() {
const updatePrompt = document.getElementById('modalRespawnPrompt').value; const updatePrompt = document.getElementById('modalRespawnPrompt').value;
const idleTimeout = parseInt(document.getElementById('modalRespawnIdleTimeout').value) || 5;
const sendClear = document.getElementById('modalRespawnSendClear').checked; const sendClear = document.getElementById('modalRespawnSendClear').checked;
const sendInit = document.getElementById('modalRespawnSendInit').checked; const sendInit = document.getElementById('modalRespawnSendInit').checked;
const kickstartPrompt = document.getElementById('modalRespawnKickstart').value.trim() || undefined;
const durationStr = document.getElementById('modalRespawnDuration').value; const durationStr = document.getElementById('modalRespawnDuration').value;
const durationMinutes = durationStr ? parseInt(durationStr) : null; 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 autoClearEnabled = document.getElementById('modalAutoClearEnabled').checked;
const autoClearThreshold = parseInt(document.getElementById('modalAutoClearThreshold').value) || 100000; const autoClearThreshold = parseInt(document.getElementById('modalAutoClearThreshold').value) || 140000;
return { return {
respawnConfig: { respawnConfig: {
updatePrompt, updatePrompt,
idleTimeoutMs: idleTimeout * 1000,
sendClear, sendClear,
sendInit, sendInit,
kickstartPrompt,
}, },
durationMinutes, durationMinutes,
autoCompactEnabled,
autoCompactThreshold,
autoCompactPrompt,
autoClearEnabled, autoClearEnabled,
autoClearThreshold autoClearThreshold
}; };
@@ -1291,7 +1301,15 @@ class ClaudemanApp {
return; return;
} }
const { respawnConfig, durationMinutes, autoClearEnabled, autoClearThreshold } = this.getModalRespawnConfig(); const {
respawnConfig,
durationMinutes,
autoCompactEnabled,
autoCompactThreshold,
autoCompactPrompt,
autoClearEnabled,
autoClearThreshold
} = this.getModalRespawnConfig();
try { try {
// Enable respawn on the session // Enable respawn on the session
@@ -1303,6 +1321,15 @@ class ClaudemanApp {
const data = await res.json(); const data = await res.json();
if (data.error) throw new Error(data.error); 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 // Set auto-clear if enabled
if (autoClearEnabled) { if (autoClearEnabled) {
await fetch(`/api/sessions/${this.editingSessionId}/auto-clear`, { await fetch(`/api/sessions/${this.editingSessionId}/auto-clear`, {
+24 -9
View File
@@ -204,14 +204,6 @@
<input type="text" id="modalRespawnPrompt" value="update all the docs and CLAUDE.md" placeholder="Prompt to send when idle"> <input type="text" id="modalRespawnPrompt" value="update all the docs and CLAUDE.md" placeholder="Prompt to send when idle">
</div> </div>
<div class="form-row">
<label>Idle Timeout</label>
<div class="input-suffix">
<input type="number" id="modalRespawnIdleTimeout" value="5" min="1" max="60">
<span>sec</span>
</div>
</div>
<div class="form-row"> <div class="form-row">
<label>Run Duration</label> <label>Run Duration</label>
<div class="input-suffix"> <div class="input-suffix">
@@ -235,6 +227,28 @@
</div> </div>
</div> </div>
<div class="form-row">
<label>Kickstart Prompt</label>
<input type="text" id="modalRespawnKickstart" value="" placeholder="Optional: prompt if /init doesn't trigger work">
<span class="form-hint">Sent only when /init completes but Claude stays idle</span>
</div>
<div class="form-row">
<label>Auto-Compact Context</label>
<div class="auto-clear-row">
<label class="checkbox-inline">
<input type="checkbox" id="modalAutoCompactEnabled">
<span>Enable at</span>
</label>
<div class="input-suffix input-suffix-sm">
<input type="number" id="modalAutoCompactThreshold" value="110000" min="10000" max="500000" step="10000">
<span>tokens</span>
</div>
</div>
<input type="text" id="modalAutoCompactPrompt" value="" placeholder="Optional: focus prompt for compact" class="mt-sm">
<span class="form-hint">Summarizes context while preserving key information</span>
</div>
<div class="form-row"> <div class="form-row">
<label>Auto-Clear Context</label> <label>Auto-Clear Context</label>
<div class="auto-clear-row"> <div class="auto-clear-row">
@@ -243,10 +257,11 @@
<span>Enable at</span> <span>Enable at</span>
</label> </label>
<div class="input-suffix input-suffix-sm"> <div class="input-suffix input-suffix-sm">
<input type="number" id="modalAutoClearThreshold" value="100000" min="10000" max="500000" step="10000"> <input type="number" id="modalAutoClearThreshold" value="140000" min="10000" max="500000" step="10000">
<span>tokens</span> <span>tokens</span>
</div> </div>
</div> </div>
<span class="form-hint">Completely resets context (use higher threshold than compact)</span>
</div> </div>
<div class="respawn-actions"> <div class="respawn-actions">
+3
View File
@@ -21,6 +21,9 @@
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
/* Utility classes */
.mt-sm { margin-top: 0.5rem; }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg-dark); background: var(--bg-dark);
+28
View File
@@ -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) // Quick run (create session, run prompt, return result, then cleanup)
this.app.post('/api/run', async (req) => { this.app.post('/api/run', async (req) => {
// Prevent unbounded session creation // Prevent unbounded session creation
@@ -912,6 +935,11 @@ export class WebServer extends EventEmitter {
this.broadcast('session:autoClear', { sessionId: session.id, ...data }); this.broadcast('session:autoClear', { sessionId: session.id, ...data });
this.broadcast('session:updated', session.toDetailedState()); 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 { private setupRespawnListeners(sessionId: string, controller: RespawnController): void {