mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
Custom Model Endpoint Profiles (#393) let a session point its CLI at a custom OpenAI-compatible endpoint by injecting env vars or a config file and restarting the CLI in place. Review of the apply path found four things, two of them destructive. This lands all four plus the smaller items from the same review. 1. Clearing a selection did not clear it. The injected vars reach the CLI via `tmux setenv`, which persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so deleting the keys from the session's envOverrides relaunched the CLI still pointed at the old endpoint, and for the configDir kinds at a HOME/CODEX_HOME/GROK_HOME that had just been deleted. `Session.setCustomModel()` now reports the removed keys, queues them (`_pendingEnvUnsets`), and `RespawnPaneOptions.unsetEnvKeys` carries them into `applyEnvOverrides()`, which `setenv -u`s them before re-applying the live overrides, on the same path that already unsets the legacy CLAUDE_CODE_EFFORT_LEVEL. Verified on a private tmux socket that `setenv -u HOME` hands the next respawn the global HOME back. 2. Applying a model to a local claude session killed the pane. The relaunch was `claude --session-id <id>` and Claude refuses an id that already has a transcript, and unlike the dead-pane respawn this one kills a working pane first. `restartCli()` now pins the live conversation id as the resume id for that respawn when the CLI's launch declares a `fallback` chain, which renders the same `--resume <id> || --session-id <id>` shape the docker and remote pane commands use. Gated on the registry shape, not the CLI id: an entry whose resume id is minted by the CLI itself never declares that chain. 3. pi, omp and grok wrote their config file and then launched without the `--model` that selects it, so the file was ignored. The registry entry now declares `customModelInjection.launchModel` (`custom/{modelId}` for pi and omp, grok's `[model.codeman-custom]` block name), the builder renders it, and `_withCustomModelLaunchModel()` applies it onto the respawn options through `legacyConfigField`, leaving the stored <Mode>Config untouched so a clear falls back to the user's own model. A model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. 4. Remote (SSH) and Docker sessions reported `restarted: true` and changed nothing: their `restartCli()` reattaches the durable tmux rather than relaunching the agent, and the env lands on the local pane. Both are refused with a 400 until those paths are plumbed. Smaller items from the same review: - The selection survives a Codeman restart as the disk-only `__customModel` bookkeeping (endpoint, model, injected key NAMES, config dir, launch model; never the values, which carry the API key). Recovery re-derives the values from the endpoint store through the same apply path the route uses and keeps the bookkeeping even when the endpoint is gone, so a later clear still has keys to unset. - Discovery goes through `webviewFetch()`, so the RESOLVED address is judged by the same egress guard the web-tab proxy uses, and `baseUrl` reuses `webviewUrlSchema` (http(s) only, no embedded credentials, link-local and cloud-metadata addresses refused). undici's `fetch failed` wrapper is unwrapped so the user sees the ECONNREFUSED underneath. - `custom-model-hosts.json` is written 0600 via tmp+rename, the per-session config dir 0700/0600 (pi and omp embed the key literally), and that dir is removed with the session. - `PR.md` is gone from the repo root and the design doc moved to `docs/custom-model-endpoints-plan.md` with the LAN address and the personal name scrubbed; every reference follows. The guide's `authStyle` text matches the shipped schema (`bearer | api-key`, default `bearer`) and says that `customModelEndpointsEnabled` is read by nothing until the picker lands. - `config/tsconfig.scripts.json` typechecks `scripts/test-local-llm-harnesses.ts` (four real type errors fixed). It is not yet wired into `npm run typecheck` because that line differs on master; adding `&& tsc -p config/tsconfig.scripts.json` there is the one-line follow-up. Tests: `test/session-custom-model-restart.test.ts` drives a real Session and fails on the unfixed code for items 1 to 3; the route suite covers item 4 and the pattern refusal; `test/tmux-manager.test.ts` pins that the unsets run before the overrides and that a shell-metachar key never reaches tmux. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
314 lines
10 KiB
TypeScript
314 lines
10 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
|
|
*/
|
|
|
|
/**
|
|
* 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.updateSessionState(session);
|
|
|
|
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 {
|
|
// envOverrides is intentionally NOT on SessionState (API safety). For disk
|
|
// persistence we augment the stored object with __envOverrides so reboot
|
|
// recovery can restore them without leaking through any API serializer.
|
|
// The key uses the reserved `__` prefix so it is visibly "internal" to any
|
|
// future reader of state.json.
|
|
const state = session.toState();
|
|
const envOverrides = session.getEnvOverridesForPersist();
|
|
// __customModel: same convention, the disk-only bookkeeping of a custom-model
|
|
// selection (env KEYS, config dir, launch model; never the injected values).
|
|
const customModel = session.getCustomModelForPersist();
|
|
const toStore = {
|
|
...state,
|
|
...(envOverrides ? { __envOverrides: envOverrides } : {}),
|
|
...(customModel ? { __customModel: customModel } : {}),
|
|
};
|
|
this.store.setSession(session.id, toStore as SessionState);
|
|
}
|
|
|
|
/** 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;
|
|
}
|