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
+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();