/** * Session manager — pi agent lifecycle via SDK. * * Pi runs in-process via createAgentSession(). Tool calls are gated * through the in-process permission extension factory. * * Handles: * - Session lifecycle (start, stop, idle timeout) * - Agent event → simplified WebSocket message translation * - SDK command passthrough (model switching, compaction, etc.) */ import { EventEmitter } from "node:events"; import type { ExtensionFactory } from "@mariozechner/pi-coding-agent"; import type { MessageQueueDraftItem, MessageQueueState, Session, ServerMessage, Workspace, } from "./types.js"; import type { Storage } from "./storage.js"; import type { GateServer } from "./gate.js"; import { WorkspaceRuntime, resolveRuntimeLimits } from "./workspace-runtime.js"; import { type PiStateSnapshot, type SessionBackendEvent } from "./pi-events.js"; import { MobileRendererRegistry } from "./mobile-renderer.js"; import type { ServerMetricCollector } from "./server-metric-collector.js"; import { createSessionCoordinatorBundle, type SessionCoordinatorBundle, } from "./session-coordinators.js"; import type { SessionCatchUpResponse } from "./session-broadcast.js"; import { type SessionStartActiveSession } from "./session-start.js"; import { type SessionStateActiveSession } from "./session-state.js"; import { type ExtensionUIResponse } from "./session-ui.js"; import { ts } from "./log-utils.js"; function parsePositiveIntEnv(name: string, fallback: number): number { const raw = process.env[name]; if (!raw) { return fallback; } const parsed = Number.parseInt(raw, 10); if (!Number.isInteger(parsed) || parsed <= 0) { return fallback; } return parsed; } // ─── Types ─── type ActiveSession = SessionStartActiveSession; // ─── Session Manager ─── export class SessionManager extends EventEmitter { private storage: Storage; private active: Map = new Map(); private pendingPromptPreambles: Map = new Map(); private pendingExtensionFactories: Map = new Map(); /** Injected by the server to resolve context window for a model ID. */ contextWindowResolver: ((modelId: string) => number) | null = null; /** Injected by the server to resolve skill names to host directory paths. */ skillPathResolver: ((skillNames: string[]) => Promise) | null = null; /** Injected by the server to return available model IDs for spawn_agent validation. */ availableModelIdsResolver: (() => string[]) | null = null; /** Injected by the server for auto-title generation on first message. */ onFirstMessage: ((session: Session) => void) | null = null; /** Injected by the server for per-turn operational metrics. */ opsMetrics: ServerMetricCollector | null = null; /** Injected by the server for full-text search index updates. */ searchIndex: { markForReindex(sessionId: string): void; flushForSession(sessionId: string): void; deleteSession(sessionId: string): void; } | null = null; private readonly mobileRenderers: MobileRendererRegistry; private mobileRenderersLoadStarted = false; private readonly broadcaster: SessionCoordinatorBundle["broadcaster"]; private readonly stateCoordinator: SessionCoordinatorBundle["stateCoordinator"]; private readonly commandCoordinator: SessionCoordinatorBundle["commandCoordinator"]; private readonly activationCoordinator: SessionCoordinatorBundle["activationCoordinator"]; private readonly lifecycleCoordinator: SessionCoordinatorBundle["lifecycleCoordinator"]; private readonly inputCoordinator: SessionCoordinatorBundle["inputCoordinator"]; private readonly queueCoordinator: SessionCoordinatorBundle["queueCoordinator"]; private readonly agentEventCoordinator: SessionCoordinatorBundle["agentEventCoordinator"]; private readonly stopFlowCoordinator: SessionCoordinatorBundle["stopFlowCoordinator"]; private readonly uiCoordinator: SessionCoordinatorBundle["uiCoordinator"]; constructor(storage: Storage, gate: GateServer, metrics?: ServerMetricCollector) { super(); this.storage = storage; if (metrics) this.opsMetrics = metrics; const config = storage.getConfig(); const runtimeManager = new WorkspaceRuntime(resolveRuntimeLimits(config)); this.mobileRenderers = new MobileRendererRegistry(); const eventRingCapacity = parsePositiveIntEnv("OPPI_SESSION_EVENT_RING_CAPACITY", 500); const bundle = createSessionCoordinatorBundle({ storage, config, gate, runtimeManager, active: this.active, mobileRenderers: this.mobileRenderers, eventRingCapacity, stopAbortTimeoutMs: this.stopAbortTimeoutMs, stopAbortRetryTimeoutMs: this.stopAbortRetryTimeoutMs, stopSessionGraceMs: this.stopSessionGraceMs, getContextWindowResolver: () => this.contextWindowResolver, getSkillPathResolver: () => this.skillPathResolver, getAndClearPendingExtensionFactories: (sessionId) => this.getAndClearPendingExtensionFactories(sessionId), emitSessionEvent: (payload) => this.emit("session_event", payload), onPiEvent: (key, event) => this.handlePiEvent(key, event), onSessionEnd: (key, reason) => this.handleSessionEnd(key, reason), persistSessionNow: (key, session) => this.persistSessionNow(key, session), markSessionDirty: (key) => this.markSessionDirty(key), resetIdleTimer: (key) => this.resetIdleTimer(key), bootstrapSessionState: (key) => this.bootstrapSessionState(key), sendCommand: (key, command) => this.sendCommand(key, command), sendCommandAsync: (key, command) => this.sendCommandAsync(key, command), broadcast: (key, message) => this.broadcast(key, message), stopSession: (sessionId) => this.stopSession(sessionId), resumeSession: (sessionId) => this.startSession(sessionId), spawnChildSession: (parentSessionId, params) => this.spawnChildSession(parentSessionId, params), spawnDetachedSession: (originSessionId, params) => this.spawnDetachedSession(originSessionId, params), listChildSessions: (parentSessionId) => this.listChildSessions(parentSessionId), subscribeToSession: (sessionId, callback) => this.subscribe(sessionId, callback), getAvailableModelIds: () => this.getAvailableModelIds(), sendMessage: (sessionId, message, behavior) => this.sendMessageToSession(sessionId, message, behavior), onFirstMessage: (session) => this.onFirstMessage?.(session), metrics: this.opsMetrics ?? undefined, }); this.broadcaster = bundle.broadcaster; this.stateCoordinator = bundle.stateCoordinator; this.commandCoordinator = bundle.commandCoordinator; this.activationCoordinator = bundle.activationCoordinator; this.lifecycleCoordinator = bundle.lifecycleCoordinator; this.inputCoordinator = bundle.inputCoordinator; this.queueCoordinator = bundle.queueCoordinator; this.agentEventCoordinator = bundle.agentEventCoordinator; this.stopFlowCoordinator = bundle.stopFlowCoordinator; this.uiCoordinator = bundle.uiCoordinator; } private getAvailableModelIds(): string[] { return this.availableModelIdsResolver?.() ?? []; } private ensureMobileRenderersLoaded(): void { if (this.mobileRenderersLoadStarted) return; this.mobileRenderersLoadStarted = true; this.mobileRenderers .loadAllRenderers() .then(({ loaded, errors }) => { if (loaded.length > 0) { console.log("[mobile-renderer] loaded", { count: loaded.length, loaded, }); } for (const err of errors) { console.error(`${ts()} [mobile-renderer] ${err}`); } }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err); console.error(`${ts()} [mobile-renderer] ${message}`); }); } // ─── Session Lifecycle ─── /** * Single-user session key. */ private sessionKey(sessionId: string): string { return sessionId; } /** * Start a new session — creates an in-process pi SDK session. */ async startSession(sessionId: string, workspace?: Workspace): Promise { const key = this.sessionKey(sessionId); this.ensureMobileRenderersLoaded(); const session = await this.activationCoordinator.startSession(key, sessionId, workspace); // Notify the parent session's subscribers so the iOS context bar updates. // When a child is stopped, iOS unsubscribes from its event stream. On // resume, the per-session subscription callback is gone, so the child's // state transitions (stopped → ready → busy) never reach the client. // Broadcasting on the parent's key ensures the context bar sees it. if (session.parentSessionId) { const parentKey = this.sessionKey(session.parentSessionId); this.broadcast(parentKey, { type: "state", session }); } return session; } /** Process a pi agent event from the SDK subscribe callback. */ private handlePiEvent(key: string, data: SessionBackendEvent): void { this.agentEventCoordinator.handlePiEvent(key, data); // Update search index on relevant events if (this.searchIndex) { const sessionId = key; // sessionKey is identity if (data.type === "message_end") { const msg = (data as Record).message as | Record | undefined; if (msg?.role === "assistant" || msg?.role === "user") { this.searchIndex.markForReindex(sessionId); } } else if (data.type === "agent_end") { this.searchIndex.flushForSession(sessionId); } } } // ─── Extension UI Protocol ─── /** * Send extension_ui_response back to pi (in-process gate). * Called by server.ts when phone responds to a UI dialog. */ respondToUIRequest(sessionId: string, response: ExtensionUIResponse): boolean { const key = this.sessionKey(sessionId); return this.uiCoordinator.respondToUIRequest(key, response); } /** * Send a prompt to pi. Handles streaming state. * * SDK prompt rules: * - If agent is idle: send as `prompt` * - If agent is streaming: must specify behavior */ async sendPrompt( sessionId: string, message: string, opts?: { images?: Array<{ type: "image"; data: string; mimeType: string }>; streamingBehavior?: "steer" | "followUp"; clientTurnId?: string; requestId?: string; timestamp?: number; }, ): Promise { const key = this.sessionKey(sessionId); const active = this.active.get(key); const preamble = active && active.session.messageCount === 0 ? this.pendingPromptPreambles.get(sessionId) : undefined; await this.inputCoordinator.sendPrompt(key, message, { ...opts, preamble, }); if (preamble && active && active.session.messageCount > 0) { this.pendingPromptPreambles.delete(sessionId); } } /** * Send a steer message (interrupt agent after current tool). * * Guard: steer is only valid while the session is actively streaming. * If called while idle, throw a deterministic error so the client can * surface feedback instead of appearing stuck. */ async sendSteer( sessionId: string, message: string, opts?: { images?: Array<{ type: "image"; data: string; mimeType: string }>; clientTurnId?: string; requestId?: string; }, ): Promise { const key = this.sessionKey(sessionId); await this.inputCoordinator.sendSteer(key, message, opts); } /** * Send a follow-up message (delivered after agent finishes). * * Guard: follow-up queueing is only meaningful while a turn is streaming. */ async sendFollowUp( sessionId: string, message: string, opts?: { images?: Array<{ type: "image"; data: string; mimeType: string }>; clientTurnId?: string; requestId?: string; }, ): Promise { const key = this.sessionKey(sessionId); await this.inputCoordinator.sendFollowUp(key, message, opts); } /** * Send a message to a session with automatic dispatch based on session state. * * - Idle/ready → sendPrompt (starts a new turn) * - Busy + behavior="steer" → sendSteer (injected mid-turn after current tool calls) * - Busy + behavior="followUp" (default) → sendFollowUp (queued after current turn) * * Used by the send_message tool in the spawn_agent extension. */ async sendMessageToSession( sessionId: string, message: string, behavior?: "steer" | "followUp", ): Promise { const session = this.storage.getSession(sessionId); if (!session) throw new Error(`Session not found: ${sessionId}`); if (session.status === "busy") { if (behavior === "steer") { await this.sendSteer(sessionId, message); } else { await this.sendFollowUp(sessionId, message); } } else { await this.sendPrompt(sessionId, message); } } getMessageQueue(sessionId: string): MessageQueueState { const key = this.sessionKey(sessionId); return this.queueCoordinator.getQueue(key); } async setMessageQueue( sessionId: string, payload: { baseVersion: number; steering: MessageQueueDraftItem[]; followUp: MessageQueueDraftItem[]; }, ): Promise { const key = this.sessionKey(sessionId); return this.queueCoordinator.setQueue(key, payload); } /** * Best-effort bootstrap of pi session metadata (session file/UUID). * * Needed so stopped sessions can still reconstruct trace history. */ private async bootstrapSessionState(key: string): Promise { const active = this.active.get(key); if (!active) return; await this.stateCoordinator.bootstrapSessionState(key, active as SessionStateActiveSession); } /** * Refresh live pi state for an active session and return trace metadata. * Used by REST trace endpoint to recover session traces. */ async refreshSessionState( sessionId: string, ): Promise<{ sessionFile?: string; sessionId?: string } | null> { const key = this.sessionKey(sessionId); const active = this.active.get(key); if (!active) return null; return this.stateCoordinator.refreshSessionState(key, active as SessionStateActiveSession); } /** * Run a SDK command against an active session and await response. * Used by HTTP workflows (e.g. server-orchestrated fork/session operations). */ async runCommand(sessionId: string, command: Record): Promise { const key = this.sessionKey(sessionId); if (!this.active.has(key)) { throw new Error(`Session not active: ${sessionId}`); } return this.sendCommandAsync(key, { ...command }); } setPendingPromptPreamble(sessionId: string, preamble: string): void { const normalized = preamble.trim(); if (normalized.length === 0) { this.pendingPromptPreambles.delete(sessionId); return; } this.pendingPromptPreambles.set(sessionId, normalized); } /** * Register extra extension factories to inject when a session starts. * Consumed and cleared by SessionStartCoordinator during startSession. */ setPendingExtensionFactories(sessionId: string, factories: ExtensionFactory[]): void { if (factories.length === 0) { this.pendingExtensionFactories.delete(sessionId); return; } this.pendingExtensionFactories.set(sessionId, factories); } /** Consume and clear pending extension factories for a session. */ private getAndClearPendingExtensionFactories(sessionId: string): ExtensionFactory[] { const factories = this.pendingExtensionFactories.get(sessionId); this.pendingExtensionFactories.delete(sessionId); return factories ?? []; } /** * Apply fields we care about from pi `get_state` response payload. * Returns true if the session object changed. */ private applyPiStateSnapshot( session: Session, state: PiStateSnapshot | null | undefined, ): boolean { return this.stateCoordinator.applyPiStateSnapshot(session, state); } // ─── SDK Command Handlers ─── /** * Forward a client WebSocket command to the pi SDK. * * Used for commands that map 1:1 to SDK methods (model switching, * thinking level, session management, etc.). The response is * broadcast back as a `command_result` ServerMessage. */ async forwardClientCommand( sessionId: string, message: Record, requestId?: string, ): Promise { const key = this.sessionKey(sessionId); await this.commandCoordinator.forwardClientCommand( key, message, requestId, (commandKey, command) => this.sendCommandAsync(commandKey, command), ); } /** * Abort the current agent operation. * * Abort the current turn. Does NOT stop the session — the SDK backend * stays alive and ready for the next prompt. */ async sendAbort(sessionId: string): Promise { const key = this.sessionKey(sessionId); // cancelPendingAsk runs inside the session lock (via preAbort callback) // to serialize with respondToUIRequest. This prevents the race where a // stop message arriving before an ask answer silently discards the answer. await this.stopFlowCoordinator.sendAbort(key, sessionId, () => { this.cancelPendingAsk(sessionId); }); } /** Graceful abort budget before escalating. */ private readonly stopAbortTimeoutMs = 8_000; /** After escalation, wait this long before giving up (session stays alive). */ private readonly stopAbortRetryTimeoutMs = 5_000; /** Grace period between abort and dispose in force-stop flow. */ private readonly stopSessionGraceMs = 1_000; // ─── SDK Commands ─── /** * Send a fire-and-forget command to the SDK backend. */ sendCommand(key: string, command: Record): void { this.commandCoordinator.sendCommand(key, command); this.resetIdleTimer(key); } /** * Send a command to the SDK backend and await the result. * Dispatches through the declarative SDK_HANDLERS map. */ async sendCommandAsync(key: string, command: Record): Promise { return this.commandCoordinator.sendCommandAsync(key, command); } // ─── Persistence ─── private markSessionDirty(key: string): void { this.broadcaster.markSessionDirty(key); } private persistSessionNow(key: string, session: Session): void { this.broadcaster.persistSessionNow(key, session); } // ─── Session End ─── private handleSessionEnd(key: string, reason: string): void { this.pendingPromptPreambles.delete(key); this.lifecycleCoordinator.handleSessionEnd(key, reason); } // ─── Subscribe / Broadcast ─── subscribe(sessionId: string, callback: (msg: ServerMessage) => void): () => void { return this.broadcaster.subscribe(this.sessionKey(sessionId), callback); } private broadcast(key: string, message: ServerMessage): void { this.broadcaster.broadcast(key, message); } // ─── Stop ─── async stopSession(sessionId: string): Promise { const key = this.sessionKey(sessionId); this.pendingPromptPreambles.delete(sessionId); // cancelPendingAsk runs inside the session lock (via preStop callback) // so it serializes with respondToUIRequest, matching the sendAbort pattern. await this.stopFlowCoordinator.stopSession(key, sessionId, () => { this.cancelPendingAsk(sessionId); }); } async stopAll(): Promise { const sessionIds = Array.from(this.active.values()).map((active) => active.session.id); await Promise.all(sessionIds.map((sessionId) => this.stopSession(sessionId))); } // ─── State Queries ─── isActive(sessionId: string): boolean { return this.active.has(this.sessionKey(sessionId)); } /** Set of session IDs currently held in memory (genuinely running). */ getActiveSessionIds(): Set { const ids = new Set(); for (const active of this.active.values()) { ids.add(active.session.id); } return ids; } getActiveSession(sessionId: string): Session | undefined { return this.active.get(this.sessionKey(sessionId))?.session; } /** Return the stored ask broadcast message if this session has a pending ask. */ getPendingAskMessage(sessionId: string): ServerMessage | undefined { return this.active.get(this.sessionKey(sessionId))?.pendingAsk?.broadcastMessage; } /** Cancel pending ask and resolve all deferred selects as cancelled. */ cancelPendingAsk(sessionId: string): void { const key = this.sessionKey(sessionId); const active = this.active.get(key); if (!active?.pendingAsk) return; const ask = active.pendingAsk; for (const { id } of ask.deferred) { this.respondToUIRequest(sessionId, { type: "extension_ui_response", id, cancelled: true, }); } // Also cancel the synthetic ask request itself active.pendingUIRequests.delete(ask.requestId); active.pendingAsk = undefined; } getToolFullOutputPath(sessionId: string, toolCallId: string): string | null { const active = this.active.get(this.sessionKey(sessionId)); if (!active) { return null; } const normalizedToolCallId = toolCallId.trim(); if (normalizedToolCallId.length === 0) { return null; } return active.toolFullOutputPaths.get(normalizedToolCallId) ?? null; } /** Return the event ring for an active session (for utilization sampling). */ getEventRing(sessionId: string): { length: number; capacity: number } | null { const active = this.active.get(this.sessionKey(sessionId)); if (!active) return null; return { length: active.eventRing.length, capacity: active.eventRing.capacity }; } getCurrentSeq(sessionId: string): number { return this.broadcaster.getCurrentSeq(this.sessionKey(sessionId)); } getCatchUp(sessionId: string, sinceSeq: number): SessionCatchUpResponse | null { return this.broadcaster.getCatchUp(this.sessionKey(sessionId), sinceSeq); } hasPendingUIRequest(sessionId: string, requestId: string): boolean { return this.uiCoordinator.hasPendingUIRequest(this.sessionKey(sessionId), requestId); } // ─── Spawn Agent ─── /** * Create a child session, start it, and send its first prompt. * Used by the spawn_agent extension to create fire-and-forget children. */ async spawnChildSession( parentSessionId: string, params: { name?: string; model?: string; thinking?: string; prompt: string; }, ): Promise { const parentSession = this.storage.getSession(parentSessionId); if (!parentSession?.workspaceId) { throw new Error(`Parent session not found or has no workspace: ${parentSessionId}`); } const workspace = this.storage.getWorkspace(parentSession.workspaceId); if (!workspace) { throw new Error(`Workspace not found: ${parentSession.workspaceId}`); } const model = params.model || parentSession.model || workspace.defaultModel; const session = this.storage.createSession(params.name, model); session.workspaceId = workspace.id; session.workspaceName = workspace.name; session.parentSessionId = parentSessionId; this.storage.saveSession(session); try { await this.startSession(session.id, workspace); if (params.thinking) { await this.forwardClientCommand(session.id, { type: "set_thinking_level", level: params.thinking, }); session.thinkingLevel = params.thinking; } await this.sendPrompt(session.id, params.prompt); session.firstMessage = params.prompt.slice(0, 200); this.storage.saveSession(session); // Broadcast child session state to the parent's subscribers so the iOS // client learns about the new child immediately (enables SubagentStatusBar // to appear without waiting for a session list REST poll). // Re-read from storage to get the latest state (SDK may have updated // status/messageCount since sendPrompt). const freshSession = this.storage.getSession(session.id) ?? session; this.broadcast(this.sessionKey(parentSessionId), { type: "state", session: freshSession }); } catch (err: unknown) { // Session created but failed to start or prompt — mark as error. // Stop the session to release the SDK process and workspace slot. try { await this.stopSession(session.id); } catch { // Best-effort cleanup — don't mask the original error. } session.status = "error"; const msg = err instanceof Error ? err.message : String(err); session.warnings = [...(session.warnings ?? []), `Spawn failed: ${msg}`]; this.storage.saveSession(session); throw err; } return session; } /** * Spawn a detached session in the same workspace as the origin session. * Unlike spawnChildSession, does NOT set parentSessionId — the new session * is fully independent and gets full capabilities (including spawn_agent). */ async spawnDetachedSession( originSessionId: string, params: { name?: string; model?: string; thinking?: string; prompt: string; }, ): Promise { const originSession = this.storage.getSession(originSessionId); if (!originSession?.workspaceId) { throw new Error(`Origin session not found or has no workspace: ${originSessionId}`); } const workspace = this.storage.getWorkspace(originSession.workspaceId); if (!workspace) { throw new Error(`Workspace not found: ${originSession.workspaceId}`); } const model = params.model || originSession.model || workspace.defaultModel; const session = this.storage.createSession(params.name, model); session.workspaceId = workspace.id; session.workspaceName = workspace.name; // No parentSessionId — this session is independent this.storage.saveSession(session); try { await this.startSession(session.id, workspace); if (params.thinking) { await this.forwardClientCommand(session.id, { type: "set_thinking_level", level: params.thinking, }); session.thinkingLevel = params.thinking; } await this.sendPrompt(session.id, params.prompt); session.firstMessage = params.prompt.slice(0, 200); this.storage.saveSession(session); } catch (err: unknown) { try { await this.stopSession(session.id); } catch { // Best-effort cleanup } session.status = "error"; const msg = err instanceof Error ? err.message : String(err); session.warnings = [...(session.warnings ?? []), `Detached spawn failed: ${msg}`]; this.storage.saveSession(session); throw err; } return session; } /** * List child sessions spawned by a given parent session. */ listChildSessions(parentSessionId: string): Session[] { return this.storage.listSessions().filter((s) => s.parentSessionId === parentSessionId); } // ─── Idle Management ─── private resetIdleTimer(key: string): void { this.lifecycleCoordinator.resetIdleTimer(key); } }