feat: add auto-clear, timed respawn, and enable on existing sessions

- Add token tracking to Session class (input/output tokens)
- Add auto-clear feature that sends /clear when token threshold reached
- Add API endpoint to enable respawn on existing running sessions
- Add timed duration setting for respawn (auto-stops after N minutes)
- Add TaskTracker class for background task detection and tree display
- Update UI with duration input, auto-clear settings, and "Enable on Current" button
- Add respawn timer display in banner when timed run is active
- Add token count display in respawn banner

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-19 00:38:11 +01:00
co-authored by Claude Opus 4.5
parent fd9254d8e5
commit ad99fe5922
8 changed files with 2240 additions and 2779 deletions
+20 -2
View File
@@ -26,6 +26,10 @@ export interface RespawnConfig {
interStepDelayMs: number;
/** Whether to enable respawn loop */
enabled: boolean;
/** Whether to send /clear after update prompt */
sendClear: boolean;
/** Whether to send /init after /clear */
sendInit: boolean;
}
export interface RespawnEvents {
@@ -43,6 +47,8 @@ const DEFAULT_CONFIG: RespawnConfig = {
updatePrompt: 'update all the docs and CLAUDE.md',
interStepDelayMs: 1000, // 1 second between steps
enabled: true,
sendClear: true, // send /clear after update prompt
sendInit: true, // send /init after /clear
};
/**
@@ -306,7 +312,14 @@ export class RespawnController extends EventEmitter {
if (this.promptDetected && !this.workingDetected) {
this.log('Update docs completed');
this.emit('stepCompleted', 'update');
this.sendClear();
// Proceed based on config
if (this.config.sendClear) {
this.sendClear();
} else if (this.config.sendInit) {
this.sendInit();
} else {
this.completeCycle();
}
}
}, 3000); // 3 second verification
}
@@ -332,7 +345,12 @@ export class RespawnController extends EventEmitter {
this.idleTimer = setTimeout(() => {
this.log('/clear completed');
this.emit('stepCompleted', 'clear');
this.sendInit();
// Proceed based on config
if (this.config.sendInit) {
this.sendInit();
} else {
this.completeCycle();
}
}, 1000); // 1 second for clear
}
}
+128
View File
@@ -2,6 +2,9 @@ import { EventEmitter } from 'node:events';
import { v4 as uuidv4 } from 'uuid';
import * as pty from 'node-pty';
import { SessionState, SessionStatus, SessionConfig } from './types.js';
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
export type { BackgroundTask } from './task-tracker.js';
// Maximum terminal buffer size in characters (default 5MB of text)
const MAX_TERMINAL_BUFFER_SIZE = 5 * 1024 * 1024;
@@ -37,6 +40,13 @@ export interface SessionEvents {
exit: (code: number | null) => void;
completion: (result: string, cost: number) => void;
terminal: (data: string) => void; // Raw terminal data
// Background task events
taskCreated: (task: BackgroundTask) => void;
taskUpdated: (task: BackgroundTask) => void;
taskCompleted: (task: BackgroundTask) => void;
taskFailed: (task: BackgroundTask, error: string) => void;
// Auto-clear event
autoClear: (data: { tokens: number; threshold: number }) => void;
}
export class Session extends EventEmitter {
@@ -62,6 +72,14 @@ export class Session extends EventEmitter {
private _isWorking: boolean = false;
private _lastPromptTime: number = 0;
private activityTimeout: NodeJS.Timeout | null = null;
private _taskTracker: TaskTracker;
// Token tracking for auto-clear
private _totalInputTokens: number = 0;
private _totalOutputTokens: number = 0;
private _autoClearThreshold: number = 100000; // Default 100k tokens
private _autoClearEnabled: boolean = false;
private _isClearing: boolean = false; // Prevent recursive clearing
constructor(config: Partial<SessionConfig> & { workingDir: string }) {
super();
@@ -69,6 +87,13 @@ export class Session extends EventEmitter {
this.workingDir = config.workingDir;
this.createdAt = config.createdAt || Date.now();
this._lastActivityAt = this.createdAt;
// Initialize task tracker and forward events
this._taskTracker = new TaskTracker();
this._taskTracker.on('taskCreated', (task) => this.emit('taskCreated', task));
this._taskTracker.on('taskUpdated', (task) => this.emit('taskUpdated', task));
this._taskTracker.on('taskCompleted', (task) => this.emit('taskCompleted', task));
this._taskTracker.on('taskFailed', (task, error) => this.emit('taskFailed', task, error));
}
get status(): SessionStatus {
@@ -123,6 +148,50 @@ export class Session extends EventEmitter {
return this._lastPromptTime;
}
get taskTracker(): TaskTracker {
return this._taskTracker;
}
get runningTaskCount(): number {
return this._taskTracker.getRunningCount();
}
get taskTree(): BackgroundTask[] {
return this._taskTracker.getTaskTree();
}
get taskStats(): { total: number; running: number; completed: number; failed: number } {
return this._taskTracker.getStats();
}
// Token tracking getters and setters
get totalTokens(): number {
return this._totalInputTokens + this._totalOutputTokens;
}
get inputTokens(): number {
return this._totalInputTokens;
}
get outputTokens(): number {
return this._totalOutputTokens;
}
get autoClearThreshold(): number {
return this._autoClearThreshold;
}
get autoClearEnabled(): boolean {
return this._autoClearEnabled;
}
setAutoClear(enabled: boolean, threshold?: number): void {
this._autoClearEnabled = enabled;
if (threshold !== undefined) {
this._autoClearThreshold = threshold;
}
}
isIdle(): boolean {
return this._status === 'idle';
}
@@ -166,6 +235,19 @@ export class Session extends EventEmitter {
maxTextOutput: MAX_TEXT_OUTPUT_SIZE,
maxMessages: MAX_MESSAGES,
},
// Background task tracking
taskStats: this._taskTracker.getStats(),
taskTree: this._taskTracker.getTaskTree(),
// Token tracking
tokens: {
input: this._totalInputTokens,
output: this._totalOutputTokens,
total: this._totalInputTokens + this._totalOutputTokens,
},
autoClear: {
enabled: this._autoClearEnabled,
threshold: this._autoClearThreshold,
},
};
}
@@ -370,12 +452,23 @@ export class Session extends EventEmitter {
this._claudeSessionId = msg.session_id;
}
// Process message for task tracking
this._taskTracker.processMessage(msg);
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
this._textOutput += block.text;
}
}
// Track tokens from usage
if (msg.message.usage) {
this._totalInputTokens += msg.message.usage.input_tokens || 0;
this._totalOutputTokens += msg.message.usage.output_tokens || 0;
// Check if we should auto-clear
this.checkAutoClear();
}
}
if (msg.type === 'result' && msg.total_cost_usd) {
@@ -396,6 +489,40 @@ export class Session extends EventEmitter {
}
}
// Check if we should auto-clear based on token threshold
private checkAutoClear(): void {
if (!this._autoClearEnabled || this._isClearing) return;
const totalTokens = this._totalInputTokens + this._totalOutputTokens;
if (totalTokens >= this._autoClearThreshold) {
this._isClearing = true;
console.log(`[Session] Auto-clear triggered: ${totalTokens} tokens >= ${this._autoClearThreshold} threshold`);
// Wait for Claude to be idle before clearing
const checkAndClear = () => {
if (!this._isWorking) {
// Send /clear command
this.write('/clear\n');
// Reset token counts
this._totalInputTokens = 0;
this._totalOutputTokens = 0;
this.emit('autoClear', { tokens: totalTokens, threshold: this._autoClearThreshold });
// Wait a moment then re-enable
setTimeout(() => {
this._isClearing = false;
}, 5000);
} else {
// Check again in 2 seconds
setTimeout(checkAndClear, 2000);
}
};
// Start checking after a short delay
setTimeout(checkAndClear, 1000);
}
}
// Send input to the PTY (for interactive sessions)
write(data: string): void {
if (this.ptyProcess) {
@@ -510,5 +637,6 @@ export class Session extends EventEmitter {
this._textOutput = '';
this._errorBuffer = '';
this._messages = [];
this._taskTracker.clear();
}
}
+284
View File
@@ -0,0 +1,284 @@
import { EventEmitter } from 'node:events';
/**
* Represents a background task spawned by Claude Code
*/
export interface BackgroundTask {
id: string;
parentId: string | null;
description: string;
subagentType: string;
status: 'running' | 'completed' | 'failed';
startTime: number;
endTime?: number;
output?: string;
children: string[];
}
export interface TaskTrackerEvents {
taskCreated: (task: BackgroundTask) => void;
taskUpdated: (task: BackgroundTask) => void;
taskCompleted: (task: BackgroundTask) => void;
taskFailed: (task: BackgroundTask, error: string) => void;
}
/**
* TaskTracker parses Claude Code's output to detect and track background tasks.
*
* Claude Code outputs JSON messages. When it spawns a task via the Task tool,
* we see tool_use blocks with the task parameters. We track these and match
* them with tool_result blocks to track completion.
*/
export class TaskTracker extends EventEmitter {
private tasks: Map<string, BackgroundTask> = new Map();
private taskStack: string[] = []; // Stack of active task IDs for nesting
private pendingToolUses: Map<string, { description: string; subagentType: string; parentId: string | null }> = new Map();
constructor() {
super();
}
/**
* Process a Claude message to detect task events
*/
processMessage(msg: any): void {
if (!msg || !msg.message?.content) return;
for (const block of msg.message.content) {
if (block.type === 'tool_use' && block.name === 'Task') {
this.handleTaskToolUse(block);
} else if (block.type === 'tool_result') {
this.handleToolResult(block);
}
}
}
/**
* Process raw terminal output to detect task patterns
* This is a fallback for when JSON parsing doesn't capture everything
*/
processTerminalOutput(data: string): void {
// Detect task launch patterns in terminal output
// Claude Code shows things like "Launching explore agent..." or similar
const launchPatterns = [
/Launching\s+(\w+)\s+agent/i,
/Starting\s+(\w+)\s+task/i,
/Spawning\s+(\w+)\s+agent/i,
];
for (const pattern of launchPatterns) {
const match = data.match(pattern);
if (match) {
// This is a heuristic detection - might create duplicate tasks
// but we dedupe by checking if we already have a running task of this type
const agentType = match[1].toLowerCase();
const existingRunning = Array.from(this.tasks.values()).find(
t => t.subagentType === agentType && t.status === 'running'
);
if (!existingRunning) {
this.createTaskFromTerminal(agentType, data);
}
}
}
// Detect task completion patterns
const completePatterns = [
/Task\s+completed/i,
/Agent\s+finished/i,
/Background\s+task\s+done/i,
];
for (const pattern of completePatterns) {
if (pattern.test(data)) {
// Complete the most recent running task
const runningTask = this.getMostRecentRunningTask();
if (runningTask) {
this.completeTask(runningTask.id);
}
}
}
}
private handleTaskToolUse(block: any): void {
const toolUseId = block.id;
const params = block.input || {};
const description = params.description || params.prompt?.substring(0, 50) || 'Background task';
const subagentType = params.subagent_type || 'general';
// Determine parent (current top of stack or null)
const parentId = this.taskStack.length > 0 ? this.taskStack[this.taskStack.length - 1] : null;
// Store pending tool use - task starts when we see activity
this.pendingToolUses.set(toolUseId, { description, subagentType, parentId });
// Create the task immediately
const task: BackgroundTask = {
id: toolUseId,
parentId,
description,
subagentType,
status: 'running',
startTime: Date.now(),
children: [],
};
this.tasks.set(toolUseId, task);
this.taskStack.push(toolUseId);
// Update parent's children list
if (parentId) {
const parent = this.tasks.get(parentId);
if (parent) {
parent.children.push(toolUseId);
}
}
this.emit('taskCreated', task);
}
private handleToolResult(block: any): void {
const toolUseId = block.tool_use_id;
const task = this.tasks.get(toolUseId);
if (task) {
task.status = block.is_error ? 'failed' : 'completed';
task.endTime = Date.now();
task.output = typeof block.content === 'string'
? block.content
: JSON.stringify(block.content);
// Remove from stack
const stackIndex = this.taskStack.indexOf(toolUseId);
if (stackIndex !== -1) {
this.taskStack.splice(stackIndex, 1);
}
if (block.is_error) {
this.emit('taskFailed', task, task.output || 'Unknown error');
} else {
this.emit('taskCompleted', task);
}
}
// Clean up pending
this.pendingToolUses.delete(toolUseId);
}
private createTaskFromTerminal(agentType: string, context: string): void {
const taskId = `terminal-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const parentId = this.taskStack.length > 0 ? this.taskStack[this.taskStack.length - 1] : null;
const task: BackgroundTask = {
id: taskId,
parentId,
description: `${agentType} agent`,
subagentType: agentType,
status: 'running',
startTime: Date.now(),
children: [],
};
this.tasks.set(taskId, task);
this.taskStack.push(taskId);
if (parentId) {
const parent = this.tasks.get(parentId);
if (parent) {
parent.children.push(taskId);
}
}
this.emit('taskCreated', task);
}
private completeTask(taskId: string): void {
const task = this.tasks.get(taskId);
if (task && task.status === 'running') {
task.status = 'completed';
task.endTime = Date.now();
const stackIndex = this.taskStack.indexOf(taskId);
if (stackIndex !== -1) {
this.taskStack.splice(stackIndex, 1);
}
this.emit('taskCompleted', task);
}
}
private getMostRecentRunningTask(): BackgroundTask | undefined {
// Return the task at the top of the stack
if (this.taskStack.length > 0) {
const taskId = this.taskStack[this.taskStack.length - 1];
return this.tasks.get(taskId);
}
return undefined;
}
/**
* Get the task tree as a nested structure
*/
getTaskTree(): BackgroundTask[] {
const rootTasks: BackgroundTask[] = [];
for (const task of this.tasks.values()) {
if (!task.parentId) {
rootTasks.push(task);
}
}
return rootTasks;
}
/**
* Get all tasks as a flat map
*/
getAllTasks(): Map<string, BackgroundTask> {
return new Map(this.tasks);
}
/**
* Get a specific task by ID
*/
getTask(taskId: string): BackgroundTask | undefined {
return this.tasks.get(taskId);
}
/**
* Get count of currently running tasks
*/
getRunningCount(): number {
let count = 0;
for (const task of this.tasks.values()) {
if (task.status === 'running') count++;
}
return count;
}
/**
* Get summary statistics
*/
getStats(): { total: number; running: number; completed: number; failed: number } {
let running = 0, completed = 0, failed = 0;
for (const task of this.tasks.values()) {
switch (task.status) {
case 'running': running++; break;
case 'completed': completed++; break;
case 'failed': failed++; break;
}
}
return { total: this.tasks.size, running, completed, failed };
}
/**
* Clear all tasks (e.g., when session is cleared)
*/
clear(): void {
this.tasks.clear();
this.taskStack = [];
this.pendingToolUses.clear();
}
}
+6
View File
@@ -70,6 +70,10 @@ export interface RespawnConfig {
interStepDelayMs: number;
/** Whether to enable respawn loop */
enabled: boolean;
/** Whether to send /clear after update prompt */
sendClear: boolean;
/** Whether to send /init after /clear */
sendInit: boolean;
}
export interface AppConfig {
@@ -211,6 +215,8 @@ export const DEFAULT_CONFIG: AppConfig = {
updatePrompt: 'update all the docs and CLAUDE.md',
interStepDelayMs: 1000, // 1 second between steps
enabled: false, // disabled by default
sendClear: true, // send /clear after update prompt
sendInit: true, // send /init after /clear
},
};
+698 -893
View File
File diff suppressed because it is too large Load Diff
+188 -295
View File
@@ -14,278 +14,195 @@
</head>
<body>
<div class="app">
<!-- Header -->
<!-- Compact Header with Session Tabs -->
<header class="header">
<div class="header-left">
<h1 class="logo">⚡ Claudeman</h1>
<div class="connection-status" id="connectionStatus">
<span class="status-dot"></span>
<span>Connecting</span>
<div class="header-brand">
<span class="logo">Claudeman</span>
</div>
<!-- Session Tabs -->
<div class="session-tabs" id="sessionTabs">
<div class="session-tab new-tab" onclick="app.createNewSession()" title="New Session (Ctrl+N)">
<span>+</span>
</div>
</div>
<div class="header-stats">
<div class="stat-item">
<span class="stat-value" id="statSessions">0</span>
<span class="stat-label">Sessions</span>
</div>
<div class="stat-item">
<span class="stat-value" id="statCost">$0.00</span>
<span class="stat-label">Total Cost</span>
</div>
<div class="stat-item">
<span class="stat-value" id="statTasks">0</span>
<span class="stat-label">Tasks Done</span>
<div class="header-right">
<div class="connection-status" id="connectionStatus">
<span class="status-dot"></span>
<span class="status-text">Connecting</span>
</div>
<div class="header-cost" id="headerCost">$0.00</div>
</div>
</header>
<!-- Timer Banner (shown when timed run is active) -->
<div class="timer-banner" id="timerBanner" style="display: none;">
<div class="timer-info">
<span class="timer-label">Time Remaining</span>
<div class="timer-content">
<span class="timer-value" id="timerValue">00:00:00</span>
<div class="timer-progress"><div class="timer-progress-fill" id="timerProgress"></div></div>
<span class="timer-meta" id="timerMeta">0 tasks | $0.00</span>
</div>
<div class="timer-progress">
<div class="timer-progress-fill" id="timerProgress"></div>
</div>
<div class="timer-meta">
<span id="timerStarted" class="timer-started">Started: --:--</span>
<span id="timerTasks">0 tasks completed</span>
<span id="timerCost">$0.00</span>
<button class="btn btn-sm btn-danger" onclick="app.stopCurrentRun()">Stop</button>
</div>
<button class="btn-icon-only" onclick="app.stopCurrentRun()" title="Stop">&#x25A0;</button>
</div>
<!-- Respawn Banner (shown when respawn loop is active) -->
<!-- Respawn Banner -->
<div class="respawn-banner" id="respawnBanner" style="display: none;">
<div class="respawn-status-info">
<span class="respawn-indicator">🔄</span>
<span class="respawn-state" id="respawnState">Watching</span>
<span class="respawn-cycles">Cycle: <span id="respawnCycleCount">0</span></span>
</div>
<div class="respawn-step" id="respawnStep"></div>
<div class="respawn-actions">
<button class="btn btn-sm" onclick="app.toggleRespawn()">
<span id="respawnToggleText">Pause</span>
</button>
<button class="btn btn-sm btn-danger" onclick="app.stopRespawn()">Stop</button>
</div>
<span class="respawn-indicator">&#x21BB;</span>
<span class="respawn-state" id="respawnState">Watching</span>
<span class="respawn-cycles">Cycle <span id="respawnCycleCount">0</span></span>
<span class="respawn-timer" id="respawnTimer" style="display: none;"></span>
<span class="respawn-tokens" id="respawnTokens" style="display: none;"></span>
<span class="respawn-step" id="respawnStep"></span>
<button class="btn-icon-only" onclick="app.stopRespawn()" title="Stop Respawn">&#x25A0;</button>
</div>
<!-- Main Content -->
<!-- Main Terminal Area -->
<main class="main">
<!-- Control Panel -->
<section class="control-panel">
<!-- Tabs -->
<div class="tabs">
<button class="tab active" data-tab="run" onclick="app.switchTab('run')">Run</button>
<button class="tab" data-tab="cases" onclick="app.switchTab('cases')">Cases</button>
<button class="tab" data-tab="settings" onclick="app.switchTab('settings')">Settings</button>
</div>
<!-- Run Tab -->
<div class="tab-content active" id="tab-run">
<!-- Quick Start Section -->
<div class="quick-start-section">
<div class="quick-start-header">
<span class="quick-start-icon">&#9889;</span>
<span class="quick-start-title">Quick Start</span>
</div>
<div class="quick-start-controls">
<select id="quickStartCase" class="quick-start-select">
<option value="testcase">testcase (default)</option>
</select>
<button class="btn btn-quick-start" onclick="app.quickStart()">
<span class="btn-icon">&#9889;</span>
Quick Start
</button>
</div>
<p class="quick-start-hint">Start an interactive Claude session in one click</p>
</div>
<div class="section-divider">
<span>or configure a custom run</span>
</div>
<div class="form-group">
<label>Prompt</label>
<textarea id="promptInput" placeholder="What should Claude do?" rows="6"></textarea>
</div>
<div class="form-group">
<label>Working Directory</label>
<div class="input-with-button">
<input type="text" id="dirInput" placeholder="Current directory">
<button class="btn btn-sm" onclick="app.selectCase()">Select Case</button>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Duration</label>
<select id="durationSelect">
<option value="0">Single run</option>
<option value="5">5 minutes</option>
<option value="15">15 minutes</option>
<option value="30">30 minutes</option>
<option value="60">1 hour</option>
<option value="120">2 hours</option>
<option value="240">4 hours</option>
<option value="480">8 hours</option>
<option value="720">12 hours</option>
<option value="custom">Custom...</option>
</select>
</div>
<div class="form-group" id="customDurationGroup" style="display: none;">
<label>Minutes</label>
<input type="number" id="customDuration" value="60" min="1" max="1440">
</div>
</div>
<!-- Respawn Settings (for Interactive Sessions) -->
<div class="respawn-settings" id="respawnSettings">
<div class="respawn-header">
<label>
<input type="checkbox" id="respawnEnabled">
Enable Auto-Respawn Loop
</label>
<button class="btn btn-sm" onclick="app.toggleRespawnDetails()">⚙️</button>
</div>
<div class="respawn-details" id="respawnDetails" style="display: none;">
<div class="form-group">
<label>Update Prompt</label>
<input type="text" id="respawnPrompt" value="update all the docs and CLAUDE.md">
</div>
<div class="form-row">
<div class="form-group">
<label>Idle Timeout (sec)</label>
<input type="number" id="respawnIdleTimeout" value="5" min="1" max="60">
</div>
<div class="form-group">
<label>Step Delay (sec)</label>
<input type="number" id="respawnStepDelay" value="1" min="0" max="10">
</div>
</div>
</div>
</div>
<div class="form-actions">
<button class="btn btn-primary btn-large" id="runBtn" onclick="app.startRun()">
<span class="btn-icon">▶</span>
Run Prompt
</button>
<button class="btn btn-large" id="interactiveBtn" onclick="app.startInteractive()" style="background: #22c55e;">
<span class="btn-icon">💻</span>
Interactive Session
</button>
</div>
</div>
<!-- Cases Tab -->
<div class="tab-content" id="tab-cases">
<div class="section-header">
<h3>Your Cases</h3>
<button class="btn btn-sm btn-primary" onclick="app.showCreateCase()">+ New Case</button>
</div>
<div class="cases-list" id="casesList">
<p class="empty-state">Loading cases...</p>
</div>
<!-- Create Case Form (hidden by default) -->
<div class="create-case-form" id="createCaseForm" style="display: none;">
<h4>Create New Case</h4>
<div class="form-group">
<label>Case Name</label>
<input type="text" id="caseNameInput" placeholder="my-project">
<span class="form-hint">Letters, numbers, hyphens, underscores only</span>
</div>
<div class="form-group">
<label>Description</label>
<input type="text" id="caseDescInput" placeholder="Project description (optional)">
</div>
<div class="form-actions">
<button class="btn btn-sm" onclick="app.hideCreateCase()">Cancel</button>
<button class="btn btn-sm btn-primary" onclick="app.createCase()">Create</button>
</div>
</div>
</div>
<!-- Settings Tab -->
<div class="tab-content" id="tab-settings">
<div class="form-group">
<label>Default Directory</label>
<input type="text" id="defaultDirInput" placeholder="~/projects">
</div>
<div class="form-group">
<label>
<input type="checkbox" id="autoScrollOutput" checked>
Auto-scroll output
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="soundOnComplete">
Sound on completion
</label>
</div>
<div class="form-actions">
<button class="btn btn-sm btn-primary" onclick="app.saveSettings()">Save Settings</button>
</div>
</div>
</section>
<!-- Terminal Panel -->
<section class="output-panel">
<div class="output-header">
<h2>Terminal</h2>
<div class="output-actions">
<div class="font-controls">
<button class="btn btn-xs" onclick="app.decreaseFontSize()" title="Decrease font size (Ctrl+-)">A-</button>
<span class="font-size-display" id="fontSizeDisplay">14px</span>
<button class="btn btn-xs" onclick="app.increaseFontSize()" title="Increase font size (Ctrl+=)">A+</button>
</div>
<button class="btn btn-sm" onclick="app.copyTerminal()" title="Copy terminal output">Copy</button>
<button class="btn btn-sm" onclick="app.clearTerminal()">Clear</button>
</div>
</div>
<div class="terminal-container" id="terminalContainer"></div>
</section>
<div class="terminal-container" id="terminalContainer"></div>
</main>
<!-- Sessions Panel (collapsible) -->
<aside class="sessions-panel" id="sessionsPanel">
<div class="sessions-header">
<div class="sessions-header-left" onclick="app.toggleSessions()">
<h3>Sessions <span class="session-count" id="sessionCount">0</span></h3>
<span class="toggle-icon" id="toggleIcon">▼</span>
<!-- Bottom Toolbar -->
<footer class="toolbar">
<div class="toolbar-left">
<!-- Quick Start -->
<div class="toolbar-group">
<button class="btn-toolbar btn-primary" onclick="app.quickStart()" title="Quick Start (Ctrl+Enter)">
Quick Start
</button>
<select id="quickStartCase" class="toolbar-select" title="Select case">
<option value="testcase">testcase</option>
</select>
</div>
<button class="btn btn-sm btn-danger kill-all-btn" onclick="event.stopPropagation(); app.killAllSessions()" title="Kill all sessions">
✕ Kill All
</button>
</div>
<div class="sessions-list" id="sessionsList"></div>
</aside>
<!-- Case Selector Modal -->
<div class="modal" id="caseSelectorModal">
<div class="modal-backdrop" onclick="app.closeCaseSelector()"></div>
<div class="modal-content">
<div class="modal-header">
<h3>Select Case</h3>
<button class="modal-close" onclick="app.closeCaseSelector()">&times;</button>
<!-- Directory -->
<div class="toolbar-group">
<button class="btn-toolbar" onclick="app.toggleDirInput()" title="Set working directory">
<span id="dirDisplay">No directory</span>
</button>
<input type="text" id="dirInput" class="toolbar-input hidden" placeholder="Working directory..." onblur="app.hideDirInput()">
</div>
<div class="modal-body">
<div class="cases-grid" id="caseSelectorGrid">
<p class="empty-state">Loading...</p>
<!-- Respawn Settings -->
<div class="toolbar-group">
<button class="btn-toolbar" onclick="app.toggleRespawnPanel()" id="respawnToggleBtn" title="Respawn settings">
Respawn
</button>
</div>
</div>
<div class="toolbar-right">
<!-- Font Controls -->
<button class="btn-toolbar btn-sm" onclick="app.decreaseFontSize()" title="Decrease font (Ctrl+-)">A-</button>
<span class="font-size-display" id="fontSizeDisplay">14</span>
<button class="btn-toolbar btn-sm" onclick="app.increaseFontSize()" title="Increase font (Ctrl++)">A+</button>
<div class="toolbar-divider"></div>
<button class="btn-toolbar" onclick="app.copyTerminal()" title="Copy terminal">Copy</button>
<button class="btn-toolbar" onclick="app.clearTerminal()" title="Clear terminal (Ctrl+L)">Clear</button>
<div class="toolbar-divider"></div>
<button class="btn-toolbar btn-danger" onclick="app.killActiveSession()" title="Kill current session">Kill</button>
</div>
</footer>
<!-- Respawn Settings Panel (slide-up) -->
<div class="respawn-panel" id="respawnPanel">
<div class="respawn-panel-header">
<span>Respawn Settings</span>
<button class="btn-close" onclick="app.toggleRespawnPanel()">&times;</button>
</div>
<div class="respawn-panel-body">
<div class="form-row">
<label>Update Prompt</label>
<input type="text" id="respawnPrompt" value="update all the docs and CLAUDE.md" placeholder="Prompt to send when idle">
</div>
<div class="form-row-inline">
<div class="form-col">
<label>Idle Timeout</label>
<div class="input-suffix">
<input type="number" id="respawnIdleTimeout" value="5" min="1" max="60">
<span>sec</span>
</div>
</div>
<div class="form-col">
<label>Step Delay</label>
<div class="input-suffix">
<input type="number" id="respawnStepDelay" value="1" min="0" max="10">
<span>sec</span>
</div>
</div>
</div>
<div class="form-row">
<label>Run Duration (optional)</label>
<div class="input-suffix">
<input type="number" id="respawnDuration" value="" min="1" placeholder="No limit">
<span>min</span>
</div>
<span class="form-hint">Leave empty for unlimited. Respawn stops after duration.</span>
</div>
<div class="form-row">
<label>After Update Prompt:</label>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="respawnSendClear" checked>
<span>Send /clear</span>
<span class="checkbox-hint">Clear conversation context</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="respawnSendInit" checked>
<span>Send /init</span>
<span class="checkbox-hint">Reinitialize session</span>
</label>
</div>
</div>
<div class="form-row">
<label>Auto-Clear Context</label>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="autoClearEnabled">
<span>Enable Auto-Clear</span>
<span class="checkbox-hint">Clear at token threshold</span>
</label>
</div>
<div class="input-suffix" style="margin-top: 0.5rem;">
<input type="number" id="autoClearThreshold" value="100000" min="10000" max="500000" step="10000">
<span>tokens</span>
</div>
</div>
<div class="form-actions">
<button class="btn-toolbar" onclick="app.toggleRespawnPanel()">Cancel</button>
<button class="btn-toolbar btn-success" onclick="app.enableRespawnOnCurrent()" id="enableOnCurrentBtn" style="display: none;">Enable on Current</button>
<button class="btn-toolbar btn-primary" onclick="app.startInteractiveWithRespawn()">Start New + Respawn</button>
</div>
</div>
</div>
<!-- Help Button -->
<button class="help-btn" onclick="app.showHelp()" title="Keyboard shortcuts (Ctrl+?)">?</button>
<!-- New Session Panel (slide-up) -->
<div class="new-session-panel" id="newSessionPanel">
<div class="respawn-panel-header">
<span>New Session</span>
<button class="btn-close" onclick="app.hideNewSessionPanel()">&times;</button>
</div>
<div class="respawn-panel-body">
<div class="form-row">
<label>Case / Project</label>
<select id="newSessionCase" class="form-select">
<option value="testcase">testcase (default)</option>
</select>
</div>
<div class="form-row">
<label>Or specify directory</label>
<input type="text" id="newSessionDir" placeholder="~/projects/my-project">
</div>
<div class="form-actions">
<button class="btn-toolbar" onclick="app.hideNewSessionPanel()">Cancel</button>
<button class="btn-toolbar btn-success" onclick="app.createSessionFromPanel()">Create Session</button>
</div>
</div>
</div>
<!-- Help Modal -->
<div class="modal" id="helpModal">
@@ -296,60 +213,36 @@
<button class="modal-close" onclick="app.closeHelp()">&times;</button>
</div>
<div class="modal-body">
<div class="shortcuts-list">
<div class="shortcut-group">
<h4>General</h4>
<div class="shortcut-row">
<kbd>Ctrl</kbd>+<kbd>Enter</kbd>
<span>Quick Start</span>
</div>
<div class="shortcut-row">
<kbd>Ctrl</kbd>+<kbd>K</kbd>
<span>Kill all sessions</span>
</div>
<div class="shortcut-row">
<kbd>Ctrl</kbd>+<kbd>?</kbd>
<span>Show this help</span>
</div>
<div class="shortcut-row">
<kbd>Escape</kbd>
<span>Close modals</span>
</div>
</div>
<div class="shortcut-group">
<h4>Terminal</h4>
<div class="shortcut-row">
<kbd>Ctrl</kbd>+<kbd>L</kbd>
<span>Clear terminal</span>
</div>
<div class="shortcut-row">
<kbd>Ctrl</kbd>+<kbd>+</kbd>
<span>Increase font size</span>
</div>
<div class="shortcut-row">
<kbd>Ctrl</kbd>+<kbd>-</kbd>
<span>Decrease font size</span>
</div>
</div>
<div class="shortcut-group">
<h4>Navigation</h4>
<div class="shortcut-row">
<kbd>Ctrl</kbd>+<kbd>1</kbd>
<span>Run tab</span>
</div>
<div class="shortcut-row">
<kbd>Ctrl</kbd>+<kbd>2</kbd>
<span>Cases tab</span>
</div>
<div class="shortcut-row">
<kbd>Ctrl</kbd>+<kbd>3</kbd>
<span>Settings tab</span>
</div>
</div>
<div class="shortcuts-grid">
<div><kbd>Ctrl</kbd>+<kbd>Enter</kbd></div><div>Quick Start</div>
<div><kbd>Ctrl</kbd>+<kbd>N</kbd></div><div>New Session</div>
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
<div><kbd>Ctrl</kbd>+<kbd>K</kbd></div><div>Kill All Sessions</div>
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
<div><kbd>Ctrl</kbd>+<kbd>?</kbd></div><div>Show Help</div>
<div><kbd>Escape</kbd></div><div>Close Panels</div>
</div>
</div>
</div>
</div>
<!-- Task Tree Panel (slide-up) -->
<div class="task-panel" id="taskPanel">
<div class="task-panel-header">
<span>Background Tasks</span>
<div class="task-panel-stats" id="taskPanelStats">0 running</div>
<button class="btn-close" onclick="app.toggleTaskPanel()">&times;</button>
</div>
<div class="task-panel-body" id="taskPanelBody">
<div class="task-empty">No background tasks</div>
</div>
</div>
<!-- Help Button -->
<button class="help-btn" onclick="app.showHelp()" title="Keyboard shortcuts (Ctrl+?)">?</button>
</div>
<script src="app.js"></script>
+796 -1587
View File
File diff suppressed because it is too large Load Diff
+120 -2
View File
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
import { homedir } from 'node:os';
import { EventEmitter } from 'node:events';
import { Session, ClaudeMessage } from '../session.js';
import { Session, ClaudeMessage, type BackgroundTask } from '../session.js';
import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js';
import { getStore } from '../state-store.js';
import { generateClaudeMd } from '../templates/claude-md.js';
@@ -48,6 +48,7 @@ export class WebServer extends EventEmitter {
private app: FastifyInstance;
private sessions: Map<string, Session> = new Map();
private respawnControllers: Map<string, RespawnController> = new Map();
private respawnTimers: Map<string, { timer: NodeJS.Timeout; endAt: number; startedAt: number }> = new Map();
private scheduledRuns: Map<string, ScheduledRun> = new Map();
private sseClients: Set<FastifyReply> = new Set();
private store = getStore();
@@ -345,7 +346,7 @@ export class WebServer extends EventEmitter {
// Start interactive session WITH respawn enabled
this.app.post('/api/sessions/:id/interactive-respawn', async (req) => {
const { id } = req.params as { id: string };
const body = req.body as { respawnConfig?: Partial<RespawnConfig> } | undefined;
const body = req.body as { respawnConfig?: Partial<RespawnConfig>; durationMinutes?: number } | undefined;
const session = this.sessions.get(id);
if (!session) {
@@ -367,6 +368,11 @@ export class WebServer extends EventEmitter {
this.setupRespawnListeners(id, controller);
controller.start();
// Set up timed stop if duration specified
if (body?.durationMinutes && body.durationMinutes > 0) {
this.setupTimedRespawn(id, body.durationMinutes);
}
this.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() });
return {
@@ -379,6 +385,69 @@ export class WebServer extends EventEmitter {
}
});
// Enable respawn on an EXISTING interactive session
this.app.post('/api/sessions/:id/respawn/enable', async (req) => {
const { id } = req.params as { id: string };
const body = req.body as { config?: Partial<RespawnConfig>; durationMinutes?: number } | undefined;
const session = this.sessions.get(id);
if (!session) {
return { error: 'Session not found' };
}
// Check if session is running (has a PID)
if (!session.pid) {
return { error: 'Session is not running. Start it first.' };
}
// Stop existing controller if any
const existingController = this.respawnControllers.get(id);
if (existingController) {
existingController.stop();
}
// Create and start new respawn controller
const controller = new RespawnController(session, body?.config);
this.respawnControllers.set(id, controller);
this.setupRespawnListeners(id, controller);
controller.start();
// Set up timed stop if duration specified
if (body?.durationMinutes && body.durationMinutes > 0) {
this.setupTimedRespawn(id, body.durationMinutes);
}
this.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() });
return {
success: true,
message: 'Respawn enabled on existing session',
respawnStatus: controller.getStatus(),
};
});
// Set auto-clear on a session
this.app.post('/api/sessions/:id/auto-clear', async (req) => {
const { id } = req.params as { id: string };
const body = req.body as { enabled: boolean; threshold?: number };
const session = this.sessions.get(id);
if (!session) {
return { error: 'Session not found' };
}
session.setAutoClear(body.enabled, body.threshold);
this.broadcast('session:updated', session.toDetailedState());
return {
success: true,
autoClear: {
enabled: session.autoClearEnabled,
threshold: session.autoClearThreshold,
},
};
});
// Quick run (create session, run prompt, return result)
this.app.post('/api/run', async (req) => {
const { prompt, workingDir } = req.body as QuickRunRequest;
@@ -585,6 +654,31 @@ export class WebServer extends EventEmitter {
this.broadcast('session:idle', { id: session.id });
this.broadcast('session:updated', session.toDetailedState());
});
// Background task events
session.on('taskCreated', (task: BackgroundTask) => {
this.broadcast('task:created', { sessionId: session.id, task });
this.broadcast('session:updated', session.toDetailedState());
});
session.on('taskUpdated', (task: BackgroundTask) => {
this.broadcast('task:updated', { sessionId: session.id, task });
});
session.on('taskCompleted', (task: BackgroundTask) => {
this.broadcast('task:completed', { sessionId: session.id, task });
this.broadcast('session:updated', session.toDetailedState());
});
session.on('taskFailed', (task: BackgroundTask, error: string) => {
this.broadcast('task:failed', { sessionId: session.id, task, error });
this.broadcast('session:updated', session.toDetailedState());
});
session.on('autoClear', (data: { tokens: number; threshold: number }) => {
this.broadcast('session:autoClear', { sessionId: session.id, ...data });
this.broadcast('session:updated', session.toDetailedState());
});
}
private setupRespawnListeners(sessionId: string, controller: RespawnController): void {
@@ -617,6 +711,30 @@ export class WebServer extends EventEmitter {
});
}
private setupTimedRespawn(sessionId: string, durationMinutes: number): void {
// Clear existing timer if any
const existing = this.respawnTimers.get(sessionId);
if (existing) {
clearTimeout(existing.timer);
}
const now = Date.now();
const endAt = now + durationMinutes * 60 * 1000;
const timer = setTimeout(() => {
// Stop respawn when time is up
const controller = this.respawnControllers.get(sessionId);
if (controller) {
controller.stop();
this.broadcast('respawn:stopped', { sessionId, reason: 'duration_expired' });
}
this.respawnTimers.delete(sessionId);
}, durationMinutes * 60 * 1000);
this.respawnTimers.set(sessionId, { timer, endAt, startedAt: now });
this.broadcast('respawn:timerStarted', { sessionId, durationMinutes, endAt, startedAt: now });
}
private async startScheduledRun(prompt: string, workingDir: string, durationMinutes: number): Promise<ScheduledRun> {
const id = uuidv4();
const now = Date.now();