Files
Codeman/src/session-manager.ts
T
Ark0NandClaude Opus 4.6 2ee9ad72e8 refactor: SSE event handlers, LLM context optimization, @fileoverview docs (#29)
* refactor: extract SSE event handlers into named class methods

Replace ~80 inline addListener closures in connectSSE() with a
declarative _SSE_HANDLER_MAP array that drives registration in a
single loop. Each handler is now a named _on* method on CodemanApp,
making them individually addressable for LLM navigation.

Add SSE_EVENTS constant object in constants.js to eliminate magic
event-type strings scattered across the frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: fix inaccuracies in CLAUDE.md

- Fix types barrel path: src/types.ts → src/types/index.ts
- Update app.js line count: ~12K → ~11.5K
- Correct route handler counts (113 → 111, per-group fixes)
- Add code style, ESM gotcha, env vars, route test, lifecycle log docs
- Add Node 22 CI note, test teardown timeout, port range

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add mobile screenshots and QR auth security writeup to README

Add 3 mobile screenshots (landing, idle, active) and expand the
mobile section with QR auth security design details and a
touch-optimized interface subsection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: bundle xterm-zerolag-input as vendor IIFE and add pre-commit hook

Build and postinstall now bundle the local xterm-zerolag-input package
as an IIFE at vendor/xterm-zerolag-input.js with global LocalEchoOverlay
shim. Add git pre-commit hook that runs prettier --check on staged .ts
files to catch format issues before CI.

Also bump constants.js and app.js cache-bust versions to 0.3.0 and add
tunnel upload URL display row in settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add cloudflared install support and interactive launch menu

- Add optional cloudflared dependency detection and installation
  across 6 distro families (macOS, Debian, Fedora, Arch, Alpine, SUSE)
- Add tunnel systemd service setup helper
- Replace post-install instructions with interactive launch menu
  (run now / systemd service / skip)
- Uninstall now cleans up both codeman-web and codeman-tunnel services

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: gitignore readme-preview.mjs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: WIP — SSE event constants, @fileoverview docs, CLAUDE.md compression

- Migrate broadcast() string literals → SseEvent.* typed constants
- Add @fileoverview with cross-domain references to all 13 type domain files
- Add @fileoverview to frontend JS modules (constants, mobile, voice, etc.)
- Add section dividers to route files for LLM scanability
- Compress CLAUDE.md: flat file list → domain table, fix counts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: optimize codebase for LLM context window efficiency

CLAUDE.md: 456 → 309 lines (32% reduction)
- Merge Commands into compact table, remove redundant bash block
- Convert Security section to dense table format
- Merge Performance + Resource Limits, Debugging + Troubleshooting
- Compress Tunnel, Memory Leak, Scripts, Screenshots sections
- Remove Key Patterns that duplicate @fileoverview in source files

Backend @fileoverview enhancements (10 priority files):
- session.ts: key methods, events, cross-domain refs
- respawn-controller.ts: state machine, idle detection layers
- ralph-tracker.ts: exports, circuit breaker, events
- ralph-loop.ts: lifecycle, persistence, events
- subagent-watcher.ts: watched patterns, teammate detection
- server.ts: coordination list, port interfaces
- state-store.ts: dual-file persistence, migration
- session-manager.ts: lifecycle methods, mutex guard
- hooks-config.ts: hook events list, categories
- sse-events.ts: category breakdown (~90 events, 17 categories)

Frontend app.js: add 6 section dividers, update @fileoverview line refs

Fix: escape glob `*/` in JSDoc that broke ESLint parser

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR #29 review bugs

- server.ts: replace hardcoded 'session:needsRefresh' with SseEvent constant
- install.sh: fix Alpine cloudflared install for non-root (download to tmpfile first)
- install.sh: replace Arch pacman (AUR-only) with direct binary download
- index.html: bump all 8 remaining cache-bust versions from v0.2.9 to v0.3.0
- mobile-handlers.js: fix @dependency annotation (keyboard-accessory.js, not constants.js)
- types/push.ts: fix layer number (4, not 5)
- subagent-watcher.ts: fix watched pattern path to include {session} segment
- constants.js: fix SSE_EVENTS count in @fileoverview (~73, not ~65)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:40:18 +01:00

311 lines
9.8 KiB
TypeScript

/**
* @fileoverview Session Manager for coordinating multiple Claude sessions.
*
* Lifecycle management for Claude CLI sessions:
* - Session creation with working directory, concurrent session limits (mutex-guarded)
* - Event forwarding from individual sessions to subscribers
* - State persistence via StateStore
* - Graceful shutdown of all sessions
*
* Key exports:
* - `SessionManager` class — coordinator, extends EventEmitter
* - `SessionManagerEvents` interface — typed event map
* - `getSessionManager()` — singleton accessor
*
* Key methods: `createSession(workingDir)`, `getSession(id)`, `getAllSessions()`,
* `removeSession(id)`, `stopAll()`
*
* @dependencies session (Session class), state-store (persistence), types (SessionState)
* @consumedby web/server, ralph-loop, respawn-controller
* @emits sessionStarted, sessionStopped, sessionError, sessionOutput, sessionCompletion
*
* @module session-manager
*/
import { EventEmitter } from 'node:events';
import { Session } from './session.js';
import { getStore } from './state-store.js';
import { SessionState } from './types.js';
/**
* Events emitted by SessionManager
*/
export interface SessionManagerEvents {
/** Fired when a new session starts successfully */
sessionStarted: (session: Session) => void;
/** Fired when a session stops (graceful or forced) */
sessionStopped: (sessionId: string) => void;
/** Fired when a session encounters an error */
sessionError: (sessionId: string, error: string) => void;
/** Fired when a session produces terminal output */
sessionOutput: (sessionId: string, output: string) => void;
/** Fired when a completion phrase is detected */
sessionCompletion: (sessionId: string, phrase: string) => void;
}
/**
* Manages multiple Claude sessions with lifecycle coordination.
*
* @description
* SessionManager acts as a coordinator for multiple Claude CLI sessions:
* - Enforces concurrent session limits from config
* - Forwards session events to subscribers
* - Persists session state to disk
* - Handles graceful shutdown
*
* @extends EventEmitter
* @fires SessionManagerEvents.sessionStarted
* @fires SessionManagerEvents.sessionStopped
* @fires SessionManagerEvents.sessionError
* @fires SessionManagerEvents.sessionOutput
* @fires SessionManagerEvents.sessionCompletion
*/
/** Stored event handlers for a session, used for cleanup */
interface SessionHandlers {
output: (data: string) => void;
error: (data: string) => void;
completion: (phrase: string) => void;
exit: () => void;
taskError: (taskId: string, error: string) => void;
}
export class SessionManager extends EventEmitter {
private sessions: Map<string, Session> = new Map();
private sessionHandlers: Map<string, SessionHandlers> = new Map();
private store = getStore();
// Mutex for session creation to prevent race conditions
private _sessionCreationLock: Promise<void> | null = null;
/**
* Creates a new SessionManager and loads previous session state.
*/
constructor() {
super();
this.loadFromStore();
}
private loadFromStore(): void {
const storedSessions = this.store.getSessions();
// Note: We don't restore actual processes, just the state
// Dead sessions are marked as stopped
for (const [id, state] of Object.entries(storedSessions)) {
if (state.status !== 'stopped') {
state.status = 'stopped';
state.pid = null;
this.store.setSession(id, state);
}
}
}
/**
* Creates and starts a new Claude session.
* Uses mutex to prevent race conditions when multiple requests arrive simultaneously.
*
* @param workingDir - Working directory for the session
* @returns The newly created session
* @throws Error if max concurrent sessions limit reached
*/
async createSession(workingDir: string): Promise<Session> {
// Wait for any pending session creation to complete (mutex pattern)
while (this._sessionCreationLock) {
await this._sessionCreationLock;
}
// Create a new lock promise that others will wait on
// Define unlock first to ensure it's always in scope before promise assignment
let unlock!: () => void;
const lockPromise = new Promise<void>((resolve) => {
unlock = resolve;
});
this._sessionCreationLock = lockPromise;
try {
const config = this.store.getConfig();
// Check limit INSIDE the lock to prevent race conditions
if (this.sessions.size >= config.maxConcurrentSessions) {
throw new Error(`Maximum concurrent sessions (${config.maxConcurrentSessions}) reached`);
}
const session = new Session({ workingDir });
// Set up event forwarding with stored handlers for cleanup
const handlers: SessionHandlers = {
output: (data: string) => {
this.emit('sessionOutput', session.id, data);
this.updateSessionState(session);
},
error: (data: string) => {
this.emit('sessionError', session.id, data);
this.updateSessionState(session);
},
completion: (phrase: string) => {
this.emit('sessionCompletion', session.id, phrase);
},
exit: () => {
this.emit('sessionStopped', session.id);
this.updateSessionState(session);
},
taskError: (taskId: string, error: string) => {
this.emit('sessionTaskError', session.id, taskId, error);
},
};
session.on('output', handlers.output);
session.on('error', handlers.error);
session.on('completion', handlers.completion);
session.on('exit', handlers.exit);
session.on('taskError', handlers.taskError);
// Store handlers for later cleanup
this.sessionHandlers.set(session.id, handlers);
await session.start();
this.sessions.set(session.id, session);
this.store.setSession(session.id, session.toState());
this.emit('sessionStarted', session);
return session;
} finally {
// Release the lock so other createSession calls can proceed
this._sessionCreationLock = null;
unlock();
}
}
/**
* Stops a session by ID.
*
* @param id - Session ID to stop
*/
async stopSession(id: string): Promise<void> {
const session = this.sessions.get(id);
if (!session) {
// Update store to mark as stopped if it exists there
const storedSession = this.store.getSession(id);
if (storedSession) {
storedSession.status = 'stopped';
storedSession.pid = null;
this.store.setSession(id, storedSession);
}
return;
}
// Remove event listeners to prevent memory leaks
const handlers = this.sessionHandlers.get(id);
if (handlers) {
session.off('output', handlers.output);
session.off('error', handlers.error);
session.off('completion', handlers.completion);
session.off('exit', handlers.exit);
session.off('taskError', handlers.taskError);
this.sessionHandlers.delete(id);
}
await session.stop();
this.sessions.delete(id);
this.updateSessionState(session);
}
/**
* Stops all active sessions.
* Uses Promise.allSettled to ensure all sessions are stopped even if some fail.
*/
async stopAllSessions(): Promise<void> {
const stopPromises = Array.from(this.sessions.keys()).map((id) => this.stopSession(id));
const results = await Promise.allSettled(stopPromises);
// Log any failures but don't throw - best effort cleanup
for (const result of results) {
if (result.status === 'rejected') {
console.error('[SessionManager] Failed to stop session:', result.reason);
}
}
}
/**
* Gets a session by ID.
* @param id - Session ID
* @returns The session or undefined if not found
*/
getSession(id: string): Session | undefined {
return this.sessions.get(id);
}
/** Gets all active sessions. */
getAllSessions(): Session[] {
return Array.from(this.sessions.values());
}
/** Gets all sessions currently idle (not processing). */
getIdleSessions(): Session[] {
return this.getAllSessions().filter((s) => s.isIdle());
}
/** Gets all sessions currently busy (processing). */
getBusySessions(): Session[] {
return this.getAllSessions().filter((s) => s.isBusy());
}
/** Gets the count of active sessions. */
getSessionCount(): number {
return this.sessions.size;
}
/** Checks if a session exists by ID. */
hasSession(id: string): boolean {
return this.sessions.has(id);
}
private updateSessionState(session: Session): void {
this.store.setSession(session.id, session.toState());
}
/** Gets all sessions from persistent storage (including stopped). */
getStoredSessions(): Record<string, SessionState> {
return this.store.getSessions();
}
/**
* Sends input to a session.
*
* @param sessionId - Session ID to send to
* @param input - Input string to send
* @throws Error if session not found
*/
async sendToSession(sessionId: string, input: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not found`);
}
await session.sendInput(input);
}
/** Gets the output buffer for a session. */
getSessionOutput(sessionId: string): string | null {
const session = this.sessions.get(sessionId);
return session?.getOutput() ?? null;
}
/** Gets the error buffer for a session. */
getSessionError(sessionId: string): string | null {
const session = this.sessions.get(sessionId);
return session?.getError() ?? null;
}
}
// Singleton instance
let managerInstance: SessionManager | null = null;
/**
* Gets or creates the singleton SessionManager instance.
* @returns The global SessionManager
*/
export function getSessionManager(): SessionManager {
if (!managerInstance) {
managerInstance = new SessionManager();
}
return managerInstance;
}