feat: session lifecycle audit log — append-only JSONL at ~/.claudeman/session-lifecycle.jsonl

Records every session create, start, exit, delete, recover, stale-clean,
mux-died, and server start/stop with timestamps, names, reasons, and exit
codes. Survives server restarts (unlike in-memory RunSummary).

- New SessionLifecycleLog singleton (src/session-lifecycle-log.ts)
- LifecycleEventType + LifecycleEntry types in types.ts
- GET /api/session-lifecycle endpoint with sessionId/event/since/limit filters
- cleanupSession() now takes reason parameter for audit trail
- state-store cleanupStaleSessions returns cleaned session IDs+names
- UI: lifecycle log modal accessible from header clipboard icon
- Auto-trims to 8k entries when exceeding 10k on server start

chore: bump version to 0.1556

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-02-19 18:30:14 +01:00
co-authored by Claude Opus 4.6
parent 342a2add10
commit 383ed5e5a0
9 changed files with 333 additions and 79 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ When user says "COM":
1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`)
2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web`
**Version**: 0.1555 (must match `package.json` for npm publish)
**Version**: 0.1556 (must match `package.json` for npm publish)
## Project Overview
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claudeman",
"version": "0.1555",
"version": "0.1556",
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+2 -2
View File
@@ -1160,7 +1160,7 @@ export class RespawnController extends EventEmitter {
/**
* Start periodic detection status updates for UI.
* Emits 'detectionUpdate' event every 500ms while running.
* Emits 'detectionUpdate' event every 2s while running.
*/
private startDetectionUpdates(): void {
this.stopDetectionUpdates();
@@ -1173,7 +1173,7 @@ export class RespawnController extends EventEmitter {
} catch (err) {
console.error(`[RespawnController] Error in detectionUpdateTimer:`, err);
}
}, 500);
}, 2000);
}
/**
+114
View File
@@ -0,0 +1,114 @@
/**
* @fileoverview Append-only JSONL audit log for session lifecycle events.
*
* Records every session creation, start, exit, deletion, recovery, and server
* start/stop to ~/.claudeman/session-lifecycle.jsonl. Survives server restarts
* (unlike RunSummary which is in-memory only).
*
* @module session-lifecycle-log
*/
import { appendFile, readFile, writeFile } from 'node:fs/promises';
import { existsSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
import type { LifecycleEventType, LifecycleEntry } from './types.js';
const MAX_LINES = 10_000;
const TRIM_TO = 8_000;
export class SessionLifecycleLog {
private filePath: string;
private writeQueue: Promise<void> = Promise.resolve();
constructor(filePath?: string) {
this.filePath = filePath || join(homedir(), '.claudeman', 'session-lifecycle.jsonl');
const dir = dirname(this.filePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true, mode: 0o700 });
}
}
/**
* Append a lifecycle event. Fire-and-forget — errors are logged but never thrown.
*/
log(entry: Omit<LifecycleEntry, 'ts'> & { ts?: number }): void {
const line = JSON.stringify({ ts: Date.now(), ...entry }) + '\n';
// Chain writes to prevent interleaving
this.writeQueue = this.writeQueue
.then(() => appendFile(this.filePath, line, 'utf-8'))
.catch((err) => {
console.error('[LifecycleLog] Failed to write:', err);
});
}
/**
* Query the log file with optional filters.
*/
async query(opts?: {
sessionId?: string;
event?: LifecycleEventType;
since?: number;
limit?: number;
}): Promise<LifecycleEntry[]> {
const limit = opts?.limit ?? 200;
let raw: string;
try {
raw = await readFile(this.filePath, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw err;
}
const lines = raw.trim().split('\n').filter(Boolean);
let entries: LifecycleEntry[] = [];
// Parse in reverse (newest first) for efficiency with limit
for (let i = lines.length - 1; i >= 0 && entries.length < limit; i--) {
try {
const entry = JSON.parse(lines[i]) as LifecycleEntry;
if (opts?.sessionId && entry.sessionId !== opts.sessionId) continue;
if (opts?.event && entry.event !== opts.event) continue;
if (opts?.since && entry.ts < opts.since) continue;
entries.push(entry);
} catch {
// Skip malformed lines
}
}
return entries;
}
/**
* Trim the log file if it exceeds MAX_LINES. Called on server start.
*/
async trimIfNeeded(): Promise<void> {
let raw: string;
try {
raw = await readFile(this.filePath, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return;
throw err;
}
const lines = raw.trim().split('\n').filter(Boolean);
if (lines.length <= MAX_LINES) return;
const trimmed = lines.slice(-TRIM_TO);
await writeFile(this.filePath, trimmed.join('\n') + '\n', 'utf-8');
console.log(`[LifecycleLog] Trimmed from ${lines.length} to ${trimmed.length} entries`);
}
}
// Singleton
let instance: SessionLifecycleLog | null = null;
export function getLifecycleLog(filePath?: string): SessionLifecycleLog {
if (!instance) {
instance = new SessionLifecycleLog(filePath);
}
return instance;
}
+7 -6
View File
@@ -368,25 +368,26 @@ export class StateStore {
* @param activeSessionIds - Set of currently active session IDs
* @returns Number of sessions cleaned up
*/
cleanupStaleSessions(activeSessionIds: Set<string>): number {
cleanupStaleSessions(activeSessionIds: Set<string>): { count: number; cleaned: Array<{ id: string; name?: string }> } {
const allSessionIds = Object.keys(this.state.sessions);
let cleanedCount = 0;
const cleaned: Array<{ id: string; name?: string }> = [];
for (const sessionId of allSessionIds) {
if (!activeSessionIds.has(sessionId)) {
const name = this.state.sessions[sessionId]?.name;
cleaned.push({ id: sessionId, name });
delete this.state.sessions[sessionId];
// Also clean up Ralph state for this session
this.ralphStates.delete(sessionId);
cleanedCount++;
}
}
if (cleanedCount > 0) {
console.log(`[StateStore] Cleaned up ${cleanedCount} stale session(s) from state`);
if (cleaned.length > 0) {
console.log(`[StateStore] Cleaned up ${cleaned.length} stale session(s) from state`);
this.save();
}
return cleanedCount;
return { count: cleaned.length, cleaned };
}
/** Returns all task states keyed by task ID. */
+26
View File
@@ -76,6 +76,32 @@ export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed'
/** TDD phase categories */
export type TddPhase = 'setup' | 'test' | 'impl' | 'verify' | 'review';
// ========== Session Lifecycle Audit Types ==========
/** Types of session lifecycle events recorded to the audit log */
export type LifecycleEventType =
| 'created' // Session object created
| 'started' // PTY process launched (interactive/shell/prompt)
| 'exit' // PTY process exited (with exit code)
| 'deleted' // cleanupSession() called — session removed
| 'recovered' // Session restored from tmux on server restart
| 'stale_cleaned' // Removed from state.json by cleanupStaleSessions()
| 'mux_died' // tmux session died (detected by reconciliation)
| 'server_started' // Server started (marker for restart detection)
| 'server_stopped'; // Server shutting down
/** A single entry in the session lifecycle audit log */
export interface LifecycleEntry {
ts: number;
event: LifecycleEventType;
sessionId: string;
name?: string;
mode?: string;
reason?: string;
exitCode?: number | null;
extra?: Record<string, unknown>;
}
// ========== Session Types ==========
/**
+60 -2
View File
@@ -2358,7 +2358,7 @@ class ClaudemanApp {
_updateLocalEchoState() {
const settings = this.loadAppSettingsFromStorage();
const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
const echoEnabled = settings.localEchoEnabled ?? (MobileDetection.isTouchDevice() || false);
const echoEnabled = settings.localEchoEnabled ?? true;
const shouldEnable = !!(echoEnabled && session);
if (this._localEchoEnabled && !shouldEnable) {
this._localEchoOverlay?.clear();
@@ -9796,7 +9796,7 @@ class ClaudemanApp {
document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true;
document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true;
document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false;
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? (MobileDetection.isTouchDevice() || false);
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? true;
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
// Claude CLI settings
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
@@ -9906,6 +9906,64 @@ class ClaudemanApp {
}
}
// ========== Session Lifecycle Log ==========
openLifecycleLog() {
document.getElementById('lifecycleModal').classList.add('active');
this.loadLifecycleLog();
}
closeLifecycleLog() {
document.getElementById('lifecycleModal').classList.remove('active');
}
async loadLifecycleLog() {
const eventFilter = document.getElementById('lifecycleFilterEvent').value;
const sessionFilter = document.getElementById('lifecycleFilterSession').value.trim();
const params = new URLSearchParams();
if (eventFilter) params.set('event', eventFilter);
if (sessionFilter) params.set('sessionId', sessionFilter);
params.set('limit', '300');
try {
const res = await fetch(`/api/session-lifecycle?${params}`);
const data = await res.json();
const tbody = document.getElementById('lifecycleTableBody');
const empty = document.getElementById('lifecycleEmpty');
if (!data.entries || data.entries.length === 0) {
tbody.innerHTML = '';
empty.style.display = '';
return;
}
empty.style.display = 'none';
const eventColors = {
created: '#4ade80', started: '#4ade80', recovered: '#4ade80',
exit: '#fbbf24', mux_died: '#f87171', deleted: '#f87171', stale_cleaned: '#f87171',
server_started: '#666', server_stopped: '#666',
};
tbody.innerHTML = data.entries.map(e => {
const time = new Date(e.ts).toLocaleString();
const color = eventColors[e.event] || '#888';
const name = e.name || (e.sessionId === '*' ? '—' : e.sessionId.slice(0, 8));
const extra = [];
if (e.exitCode !== undefined && e.exitCode !== null) extra.push(`code=${e.exitCode}`);
if (e.mode) extra.push(e.mode);
return `<tr style="border-bottom:1px solid #1a1a2e">
<td style="padding:3px 8px;color:#888;white-space:nowrap">${time}</td>
<td style="padding:3px 8px;color:${color};font-weight:600">${e.event}</td>
<td style="padding:3px 8px;color:#e0e0e0" title="${e.sessionId}">${name}</td>
<td style="padding:3px 8px;color:#aaa">${e.reason || ''}</td>
<td style="padding:3px 8px;color:#666">${extra.join(', ')}</td>
</tr>`;
}).join('');
} catch (err) {
console.error('Failed to load lifecycle log:', err);
}
}
async saveAppSettings() {
const settings = {
defaultClaudeMdPath: document.getElementById('appSettingsClaudeMdPath').value.trim(),
+44 -1
View File
@@ -84,6 +84,7 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
</button>
<button class="btn-icon-header" onclick="app.openLifecycleLog()" title="Session Lifecycle Log" aria-label="Open session lifecycle log" style="font-size:14px;opacity:0.7">&#x1F4CB;</button>
<button class="btn-icon-header btn-settings" onclick="app.openAppSettings()" title="App Settings" aria-label="Open app settings"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
<div class="header-tokens" id="headerTokens" title="Total tokens across all sessions">0 tokens</div>
</div>
@@ -880,7 +881,7 @@
<!-- Input Options Section -->
<div class="settings-section-header">Input</div>
<div class="settings-item" title="Show typed characters instantly via overlay for high-latency connections. Only active when session is idle.">
<div class="settings-item" title="Buffer keystrokes locally and show them instantly via overlay. Text is sent to the server on Enter. Useful for high-latency connections or to compose input before sending.">
<span class="settings-item-label">Local Echo</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsLocalEcho">
@@ -1500,6 +1501,48 @@
</div>
</div>
<!-- Session Lifecycle Audit Log Modal -->
<div class="modal" id="lifecycleModal">
<div class="modal-backdrop" onclick="app.closeLifecycleLog()"></div>
<div class="modal-content modal-lg">
<div class="modal-header">
<h3>Session Lifecycle Log</h3>
<button class="modal-close" onclick="app.closeLifecycleLog()" aria-label="Close lifecycle log">&times;</button>
</div>
<div class="modal-body" style="max-height:70vh;overflow:auto;padding:8px">
<div style="display:flex;gap:8px;margin-bottom:8px;flex-wrap:wrap">
<select id="lifecycleFilterEvent" style="background:#1a1a2e;color:#e0e0e0;border:1px solid #333;border-radius:4px;padding:4px 8px;font-size:12px">
<option value="">All Events</option>
<option value="created">Created</option>
<option value="started">Started</option>
<option value="exit">Exit</option>
<option value="deleted">Deleted</option>
<option value="recovered">Recovered</option>
<option value="stale_cleaned">Stale Cleaned</option>
<option value="mux_died">Mux Died</option>
<option value="server_started">Server Started</option>
<option value="server_stopped">Server Stopped</option>
</select>
<input id="lifecycleFilterSession" type="text" placeholder="Filter by session ID..." style="background:#1a1a2e;color:#e0e0e0;border:1px solid #333;border-radius:4px;padding:4px 8px;font-size:12px;flex:1;min-width:120px">
<button class="btn btn-sm btn-secondary" onclick="app.loadLifecycleLog()" style="font-size:12px;padding:4px 10px">Refresh</button>
</div>
<table id="lifecycleTable" style="width:100%;border-collapse:collapse;font-size:12px;font-family:monospace">
<thead>
<tr style="color:#888;text-align:left;border-bottom:1px solid #333">
<th style="padding:4px 8px">Time</th>
<th style="padding:4px 8px">Event</th>
<th style="padding:4px 8px">Session</th>
<th style="padding:4px 8px">Reason</th>
<th style="padding:4px 8px">Extra</th>
</tr>
</thead>
<tbody id="lifecycleTableBody"></tbody>
</table>
<div id="lifecycleEmpty" style="color:#666;text-align:center;padding:20px;display:none">No lifecycle entries found</div>
</div>
</div>
</div>
<!-- SVG Overlay for Subagent Connection Lines -->
<svg id="connectionLines" class="connection-lines-svg">
<!-- Lines drawn dynamically -->
+78 -66
View File
@@ -37,6 +37,7 @@ import { v4 as uuidv4 } from 'uuid';
import { createRequire } from 'node:module';
import { RunSummaryTracker } from '../run-summary.js';
import { PlanOrchestrator, type DetailedPlanResult } from '../plan-orchestrator.js';
import { getLifecycleLog } from '../session-lifecycle-log.js';
// Load version from package.json
const require = createRequire(import.meta.url);
@@ -110,8 +111,6 @@ interface ScheduledRun {
// Batch terminal data for performance - collect for 16ms (60fps) before sending
const TERMINAL_BATCH_INTERVAL = 16;
// Batch session:output events for 50ms
const OUTPUT_BATCH_INTERVAL = 50;
// Batch task:updated events for 100ms
const TASK_UPDATE_BATCH_INTERVAL = 100;
@@ -306,7 +305,6 @@ function getOrCreateSelfSignedCert(): { key: string; cert: string } {
/** Stored listener references for session cleanup (prevents memory leaks) */
interface SessionListenerRefs {
output: (data: string) => void;
terminal: (data: string) => void;
clearTerminal: () => void;
message: (msg: ClaudeMessage) => void;
@@ -356,6 +354,7 @@ export class WebServer extends EventEmitter {
private mux: TerminalMultiplexer;
// Terminal batching for performance
private terminalBatches: Map<string, string[]> = new Map();
private terminalBatchSizes: Map<string, number> = new Map(); // Running total avoids O(n) reduce per push
private terminalBatchTimer: NodeJS.Timeout | null = null;
// Adaptive batching: track rapid events to extend batch window (per-session)
// StaleExpirationMap auto-cleans entries for sessions that stop generating output
@@ -370,8 +369,6 @@ export class WebServer extends EventEmitter {
// Scheduled runs cleanup timer
private scheduledCleanupTimer: NodeJS.Timeout | null = null;
// SSE event batching
private outputBatches: Map<string, string> = new Map();
private outputBatchTimer: NodeJS.Timeout | null = null;
private taskUpdateBatches: Map<string, { sessionId: string; task: BackgroundTask }> = new Map();
private taskUpdateBatchTimer: NodeJS.Timeout | null = null;
// State update batching (reduce expensive toDetailedState() serialization)
@@ -444,6 +441,11 @@ export class WebServer extends EventEmitter {
this.broadcast('mux:killed', data);
});
this.mux.on('sessionDied', (data) => {
getLifecycleLog().log({
event: 'mux_died',
sessionId: (data as { sessionId?: string }).sessionId || 'unknown',
extra: data as Record<string, unknown>,
});
this.broadcast('mux:died', data);
});
this.mux.on('statsUpdated', (sessions) => {
@@ -673,6 +675,19 @@ export class WebServer extends EventEmitter {
return { success: true, cleanedSessions: cleaned };
});
// Session lifecycle audit log
this.app.get('/api/session-lifecycle', async (req) => {
const query = req.query as { sessionId?: string; event?: string; since?: string; limit?: string };
const lifecycleLog = getLifecycleLog();
const entries = await lifecycleLog.query({
sessionId: query.sessionId,
event: query.event as import('../types.js').LifecycleEventType,
since: query.since ? Number(query.since) : undefined,
limit: query.limit ? Math.min(Number(query.limit), 1000) : 200,
});
return { success: true, entries };
});
// Global stats endpoint
this.app.get('/api/stats', async () => {
const activeSessionTokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
@@ -737,7 +752,6 @@ export class WebServer extends EventEmitter {
transcriptWatchers: this.transcriptWatchers.size,
scheduledRuns: this.scheduledRuns.size,
terminalBatches: this.terminalBatches.size,
outputBatches: this.outputBatches.size,
taskUpdateBatches: this.taskUpdateBatches.size,
stateUpdatePending: this.stateUpdatePending.size,
lastRecordedTokens: this.lastRecordedTokens.size,
@@ -842,6 +856,7 @@ export class WebServer extends EventEmitter {
this.store.incrementSessionsCreated();
this.persistSessionState(session);
await this.setupSessionListeners(session);
getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name });
const detailedState = session.toDetailedState();
this.broadcast('session:created', detailedState);
@@ -905,7 +920,7 @@ export class WebServer extends EventEmitter {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
await this.cleanupSession(id, killMux);
await this.cleanupSession(id, killMux, 'user_delete');
return { success: true };
});
@@ -916,7 +931,7 @@ export class WebServer extends EventEmitter {
for (const id of sessionIds) {
if (this.sessions.has(id)) {
await this.cleanupSession(id);
await this.cleanupSession(id, true, 'user_bulk_delete');
killed++;
}
}
@@ -1641,6 +1656,7 @@ export class WebServer extends EventEmitter {
}
await session.startInteractive();
getLifecycleLog().log({ event: 'started', sessionId: id, name: session.name, mode: 'claude' });
this.broadcast('session:interactive', { id });
this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
@@ -1665,6 +1681,7 @@ export class WebServer extends EventEmitter {
try {
await session.startShell();
getLifecycleLog().log({ event: 'started', sessionId: id, name: session.name, mode: 'shell' });
this.broadcast('session:interactive', { id, mode: 'shell' });
this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
return { success: true };
@@ -1988,6 +2005,7 @@ export class WebServer extends EventEmitter {
// Start interactive session
await session.startInteractive();
getLifecycleLog().log({ event: 'started', sessionId: id, name: session.name, mode: 'claude', reason: 'interactive_respawn' });
this.broadcast('session:interactive', { id });
this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
@@ -2222,17 +2240,18 @@ export class WebServer extends EventEmitter {
this.store.incrementSessionsCreated();
this.persistSessionState(session);
await this.setupSessionListeners(session);
getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name, reason: 'run_prompt' });
this.broadcast('session:created', session.toDetailedState());
try {
const result = await session.runPrompt(prompt);
// Clean up session after completion to prevent memory leak
await this.cleanupSession(session.id);
await this.cleanupSession(session.id, true, 'run_prompt_complete');
return { success: true, sessionId: session.id, ...result };
} catch (err) {
// Clean up session on error too
await this.cleanupSession(session.id);
await this.cleanupSession(session.id, true, 'run_prompt_error');
return { success: false, sessionId: session.id, error: getErrorMessage(err) };
}
});
@@ -2640,15 +2659,18 @@ export class WebServer extends EventEmitter {
this.store.incrementSessionsCreated();
this.persistSessionState(session);
await this.setupSessionListeners(session);
getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name, reason: 'quick_start' });
this.broadcast('session:created', session.toDetailedState());
// Start in the appropriate mode
try {
if (mode === 'shell') {
await session.startShell();
getLifecycleLog().log({ event: 'started', sessionId: session.id, name: session.name, mode: 'shell' });
this.broadcast('session:interactive', { id: session.id, mode: 'shell' });
} else {
await session.startInteractive();
getLifecycleLog().log({ event: 'started', sessionId: session.id, name: session.name, mode: 'claude' });
this.broadcast('session:interactive', { id: session.id });
}
this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
@@ -2685,7 +2707,7 @@ export class WebServer extends EventEmitter {
};
} catch (err) {
// Clean up session on error to prevent orphaned resources
await this.cleanupSession(session.id);
await this.cleanupSession(session.id, true, 'quick_start_error');
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
});
@@ -3961,20 +3983,28 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// Track sessions currently being cleaned up to prevent concurrent cleanup races
private cleaningUp: Set<string> = new Set();
private async cleanupSession(sessionId: string, killMux: boolean = true): Promise<void> {
private async cleanupSession(sessionId: string, killMux: boolean = true, reason?: string): Promise<void> {
// Guard against concurrent cleanup of the same session
if (this.cleaningUp.has(sessionId)) return;
this.cleaningUp.add(sessionId);
try {
await this._doCleanupSession(sessionId, killMux);
await this._doCleanupSession(sessionId, killMux, reason);
} finally {
this.cleaningUp.delete(sessionId);
}
}
private async _doCleanupSession(sessionId: string, killMux: boolean): Promise<void> {
private async _doCleanupSession(sessionId: string, killMux: boolean, reason?: string): Promise<void> {
const session = this.sessions.get(sessionId);
const lifecycleLog = getLifecycleLog();
lifecycleLog.log({
event: 'deleted',
sessionId,
name: session?.name,
mode: session?.mode,
reason: reason || 'unknown',
});
// Stop watching @fix_plan.md for this session
if (session) {
@@ -4035,7 +4065,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// Clear batches and pending state updates
this.terminalBatches.delete(sessionId);
this.outputBatches.delete(sessionId);
this.terminalBatchSizes.delete(sessionId);
this.taskUpdateBatches.delete(sessionId);
this.stateUpdatePending.delete(sessionId);
this.lastTerminalEventTime.delete(sessionId);
@@ -4080,7 +4110,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// Explicitly remove stored listeners to break closure references (prevents memory leak)
const listeners = this.sessionListenerRefs.get(sessionId);
if (listeners) {
session.off('output', listeners.output);
session.off('terminal', listeners.terminal);
session.off('clearTerminal', listeners.clearTerminal);
session.off('message', listeners.message);
@@ -4142,11 +4171,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// Store all listener references for explicit cleanup on session delete
// This prevents memory leaks from closure references keeping objects alive
const listeners: SessionListenerRefs = {
output: (data) => {
// Use batching for better performance at high throughput
this.batchOutputData(session.id, data);
},
terminal: (data) => {
// Use batching for better performance at high throughput
this.batchTerminalData(session.id, data);
@@ -4178,6 +4202,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
},
exit: (code) => {
getLifecycleLog().log({ event: 'exit', sessionId: session.id, name: session.name, exitCode: code });
// Wrap in try/catch to ensure cleanup always happens
try {
this.broadcast('session:exit', { id: session.id, code });
@@ -4352,7 +4377,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
this.sessionListenerRefs.set(session.id, listeners);
// Attach all listeners to the session
session.on('output', listeners.output);
session.on('terminal', listeners.terminal);
session.on('clearTerminal', listeners.clearTerminal);
session.on('message', listeners.message);
@@ -4721,7 +4745,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
this.broadcast('scheduled:updated', run);
// Clean up the session after iteration to prevent memory leaks
await this.cleanupSession(session.id);
await this.cleanupSession(session.id, true, 'scheduled_run');
run.sessionId = null;
// Small pause between iterations
@@ -4733,7 +4757,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// Clean up the session on error too
if (session) {
try {
await this.cleanupSession(session.id);
await this.cleanupSession(session.id, true, 'scheduled_run_error');
} catch {
// Ignore cleanup errors
}
@@ -4762,7 +4786,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// Use cleanupSession for proper resource cleanup (listeners, respawn, etc.)
if (run.sessionId && this.sessions.has(run.sessionId)) {
await this.cleanupSession(run.sessionId);
await this.cleanupSession(run.sessionId, true, 'scheduled_run_stopped');
run.sessionId = null;
}
@@ -4832,7 +4856,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
*/
private cleanupStaleSessions(): number {
const activeSessionIds = new Set(this.sessions.keys());
return this.store.cleanupStaleSessions(activeSessionIds);
const result = this.store.cleanupStaleSessions(activeSessionIds);
const lifecycleLog = getLifecycleLog();
for (const s of result.cleaned) {
lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name });
}
return result.count;
}
/**
@@ -4909,8 +4938,16 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
private broadcast(event: string, data: unknown): void {
// Invalidate caches on any state-changing broadcast
if (event.startsWith('session:') || event.startsWith('respawn:')) {
// Invalidate caches on state-changing broadcasts, but NOT on high-frequency
// streaming events that don't change session metadata (terminal data,
// detection updates). These fire every 16ms-2s and would make the 1s TTL
// caches permanently empty — defeating their purpose.
if (
(event.startsWith('session:') || event.startsWith('respawn:')) &&
event !== 'session:terminal' &&
event !== 'session:needsRefresh' &&
event !== 'respawn:detectionUpdate'
) {
this.cachedLightState = null;
this.cachedSessionsList = null;
}
@@ -4940,7 +4977,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
this.terminalBatches.set(sessionId, chunks);
}
chunks.push(data);
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
const prevSize = this.terminalBatchSizes.get(sessionId) ?? 0;
const totalLength = prevSize + data.length;
this.terminalBatchSizes.set(sessionId, totalLength);
// Adaptive batching: detect rapid events and extend batch window (per-session)
const now = Date.now();
@@ -4991,6 +5030,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// Skip if server is stopping (timer may have been queued before stop() was called)
if (this._isStopping) {
this.terminalBatches.clear();
this.terminalBatchSizes.clear();
return;
}
for (const [sessionId, chunks] of this.terminalBatches) {
@@ -5006,36 +5046,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
}
this.terminalBatches.clear();
}
// Batch session:output events at 50ms for better performance
private batchOutputData(sessionId: string, data: string): void {
// Skip if server is stopping
if (this._isStopping) return;
const existing = this.outputBatches.get(sessionId) || '';
this.outputBatches.set(sessionId, existing + data);
if (!this.outputBatchTimer) {
this.outputBatchTimer = setTimeout(() => {
this.flushOutputBatches();
this.outputBatchTimer = null;
}, OUTPUT_BATCH_INTERVAL);
}
}
private flushOutputBatches(): void {
// Skip if server is stopping (timer may have been queued before stop() was called)
if (this._isStopping) {
this.outputBatches.clear();
return;
}
for (const [sessionId, data] of this.outputBatches) {
if (data.length > 0) {
this.broadcast('session:output', { id: sessionId, data });
}
}
this.outputBatches.clear();
this.terminalBatchSizes.clear();
}
// Batch task:updated events at 100ms - only send latest update per task
@@ -5161,6 +5172,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
async start(): Promise<void> {
await this.setupRoutes();
const lifecycleLog = getLifecycleLog();
lifecycleLog.log({ event: 'server_started', sessionId: '*' });
await lifecycleLog.trimIfNeeded();
// Restore mux sessions BEFORE accepting connections
// This prevents race conditions where clients connect before state is ready
// CRITICAL: Skip in test mode to prevent tests from picking up user sessions
@@ -5391,6 +5406,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
this.persistSessionState(session);
// Mark it as restored (not started yet - user needs to attach)
getLifecycleLog().log({ event: 'recovered', sessionId: session.id, name: session.name });
console.log(`[Server] Restored session ${session.id} from mux ${muxSession.muxName}`);
}
}
@@ -5415,6 +5431,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
async stop(): Promise<void> {
getLifecycleLog().log({ event: 'server_stopped', sessionId: '*' });
// Set stopping flag to prevent new timer creation during shutdown
this._isStopping = true;
@@ -5443,12 +5460,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
this.terminalBatchTimer = null;
}
this.terminalBatches.clear();
if (this.outputBatchTimer) {
clearTimeout(this.outputBatchTimer);
this.outputBatchTimer = null;
}
this.outputBatches.clear();
this.terminalBatchSizes.clear();
if (this.taskUpdateBatchTimer) {
clearTimeout(this.taskUpdateBatchTimer);
@@ -5514,7 +5526,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// Don't kill mux sessions on server stop - they can be reattached on restart
// Use Promise.race with a 30s timeout to prevent shutdown from hanging indefinitely
const sessionCleanup = Promise.allSettled(
Array.from(this.sessions.keys()).map(id => this.cleanupSession(id, false))
Array.from(this.sessions.keys()).map(id => this.cleanupSession(id, false, 'server_shutdown'))
);
const shutdownTimeout = new Promise<void>(resolve => setTimeout(resolve, 30_000));
await Promise.race([sessionCleanup, shutdownTimeout]);