import { copyFileSync, existsSync, mkdirSync } from "node:fs"; import { basename, join, resolve } from "node:path"; import type { AgentSession } from "./agent-session.js"; import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.js"; import type { SessionStartEvent } from "./extensions/index.js"; import { emitSessionShutdownEvent } from "./extensions/runner.js"; import type { CreateAgentSessionResult } from "./sdk.js"; import { assertSessionCwdExists } from "./session-cwd.js"; import { SessionManager } from "./session-manager.js"; /** * Result returned by runtime creation. * * The caller gets the created session, its cwd-bound services, and all * diagnostics collected during setup. */ export interface CreateAgentSessionRuntimeResult extends CreateAgentSessionResult { services: AgentSessionServices; diagnostics: AgentSessionRuntimeDiagnostic[]; } /** * Creates a full runtime for a target cwd and session manager. * * The factory closes over process-global fixed inputs, recreates cwd-bound * services for the effective cwd, resolves session options against those * services, and finally creates the AgentSession. */ export type CreateAgentSessionRuntimeFactory = (options: { cwd: string; agentDir: string; sessionManager: SessionManager; sessionStartEvent?: SessionStartEvent; }) => Promise; export class SessionImportFileNotFoundError extends Error { readonly filePath: string; constructor(filePath: string) { super(`File not found: ${filePath}`); this.name = "SessionImportFileNotFoundError"; this.filePath = filePath; } } function extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string { if (typeof content === "string") { return content; } return content .filter((part): part is { type: "text"; text: string } => part.type === "text" && typeof part.text === "string") .map((part) => part.text) .join(""); } /** * Owns the current AgentSession plus its cwd-bound services. * * Session replacement methods tear down the current runtime first, then create * and apply the next runtime. If creation fails, the error is propagated to the * caller. The caller is responsible for user-facing error handling. */ export class AgentSessionRuntime { constructor( private _session: AgentSession, private _services: AgentSessionServices, private readonly createRuntime: CreateAgentSessionRuntimeFactory, private _diagnostics: AgentSessionRuntimeDiagnostic[] = [], private _modelFallbackMessage?: string, ) {} get services(): AgentSessionServices { return this._services; } get session(): AgentSession { return this._session; } get cwd(): string { return this._services.cwd; } get diagnostics(): readonly AgentSessionRuntimeDiagnostic[] { return this._diagnostics; } get modelFallbackMessage(): string | undefined { return this._modelFallbackMessage; } private async emitBeforeSwitch( reason: "new" | "resume", targetSessionFile?: string, ): Promise<{ cancelled: boolean }> { const runner = this.session.extensionRunner; if (!runner?.hasHandlers("session_before_switch")) { return { cancelled: false }; } const result = await runner.emit({ type: "session_before_switch", reason, targetSessionFile, }); return { cancelled: result?.cancel === true }; } private async emitBeforeFork(entryId: string): Promise<{ cancelled: boolean }> { const runner = this.session.extensionRunner; if (!runner?.hasHandlers("session_before_fork")) { return { cancelled: false }; } const result = await runner.emit({ type: "session_before_fork", entryId, }); return { cancelled: result?.cancel === true }; } private async teardownCurrent(): Promise { await emitSessionShutdownEvent(this.session.extensionRunner); this.session.dispose(); } private apply(result: CreateAgentSessionRuntimeResult): void { if (process.cwd() !== result.services.cwd) { process.chdir(result.services.cwd); } this._session = result.session; this._services = result.services; this._diagnostics = result.diagnostics; this._modelFallbackMessage = result.modelFallbackMessage; } async switchSession(sessionPath: string, cwdOverride?: string): Promise<{ cancelled: boolean }> { const beforeResult = await this.emitBeforeSwitch("resume", sessionPath); if (beforeResult.cancelled) { return beforeResult; } const previousSessionFile = this.session.sessionFile; const sessionManager = SessionManager.open(sessionPath, undefined, cwdOverride); assertSessionCwdExists(sessionManager, this.cwd); await this.teardownCurrent(); this.apply( await this.createRuntime({ cwd: sessionManager.getCwd(), agentDir: this.services.agentDir, sessionManager, sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile }, }), ); return { cancelled: false }; } async newSession(options?: { parentSession?: string; setup?: (sessionManager: SessionManager) => Promise; }): Promise<{ cancelled: boolean }> { const beforeResult = await this.emitBeforeSwitch("new"); if (beforeResult.cancelled) { return beforeResult; } const previousSessionFile = this.session.sessionFile; const sessionDir = this.session.sessionManager.getSessionDir(); const sessionManager = SessionManager.create(this.cwd, sessionDir); if (options?.parentSession) { sessionManager.newSession({ parentSession: options.parentSession }); } await this.teardownCurrent(); this.apply( await this.createRuntime({ cwd: this.cwd, agentDir: this.services.agentDir, sessionManager, sessionStartEvent: { type: "session_start", reason: "new", previousSessionFile }, }), ); if (options?.setup) { await options.setup(this.session.sessionManager); this.session.agent.state.messages = this.session.sessionManager.buildSessionContext().messages; } return { cancelled: false }; } async fork(entryId: string): Promise<{ cancelled: boolean; selectedText?: string }> { const beforeResult = await this.emitBeforeFork(entryId); if (beforeResult.cancelled) { return { cancelled: true }; } const selectedEntry = this.session.sessionManager.getEntry(entryId); if (!selectedEntry || selectedEntry.type !== "message" || selectedEntry.message.role !== "user") { throw new Error("Invalid entry ID for forking"); } const previousSessionFile = this.session.sessionFile; const selectedText = extractUserMessageText(selectedEntry.message.content); if (this.session.sessionManager.isPersisted()) { const currentSessionFile = this.session.sessionFile; if (!currentSessionFile) { throw new Error("Persisted session is missing a session file"); } const sessionDir = this.session.sessionManager.getSessionDir(); if (!selectedEntry.parentId) { const sessionManager = SessionManager.create(this.cwd, sessionDir); sessionManager.newSession({ parentSession: currentSessionFile }); await this.teardownCurrent(); this.apply( await this.createRuntime({ cwd: this.cwd, agentDir: this.services.agentDir, sessionManager, sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile }, }), ); return { cancelled: false, selectedText }; } const sourceManager = SessionManager.open(currentSessionFile, sessionDir); const forkedSessionPath = sourceManager.createBranchedSession(selectedEntry.parentId); if (!forkedSessionPath) { throw new Error("Failed to create forked session"); } const sessionManager = SessionManager.open(forkedSessionPath, sessionDir); await this.teardownCurrent(); this.apply( await this.createRuntime({ cwd: sessionManager.getCwd(), agentDir: this.services.agentDir, sessionManager, sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile }, }), ); return { cancelled: false, selectedText }; } const sessionManager = this.session.sessionManager; if (!selectedEntry.parentId) { sessionManager.newSession({ parentSession: this.session.sessionFile }); } else { sessionManager.createBranchedSession(selectedEntry.parentId); } await this.teardownCurrent(); this.apply( await this.createRuntime({ cwd: this.cwd, agentDir: this.services.agentDir, sessionManager, sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile }, }), ); return { cancelled: false, selectedText }; } async importFromJsonl(inputPath: string, cwdOverride?: string): Promise<{ cancelled: boolean }> { const resolvedPath = resolve(inputPath); if (!existsSync(resolvedPath)) { throw new SessionImportFileNotFoundError(resolvedPath); } const sessionDir = this.session.sessionManager.getSessionDir(); if (!existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); } const destinationPath = join(sessionDir, basename(resolvedPath)); const beforeResult = await this.emitBeforeSwitch("resume", destinationPath); if (beforeResult.cancelled) { return beforeResult; } const previousSessionFile = this.session.sessionFile; if (resolve(destinationPath) !== resolvedPath) { copyFileSync(resolvedPath, destinationPath); } const sessionManager = SessionManager.open(destinationPath, sessionDir, cwdOverride); assertSessionCwdExists(sessionManager, this.cwd); await this.teardownCurrent(); this.apply( await this.createRuntime({ cwd: sessionManager.getCwd(), agentDir: this.services.agentDir, sessionManager, sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile }, }), ); return { cancelled: false }; } async dispose(): Promise { await emitSessionShutdownEvent(this.session.extensionRunner); this.session.dispose(); } } /** * Create the initial runtime from a runtime factory and initial session target. * * The same factory is stored on the returned AgentSessionRuntime and reused for * later /new, /resume, /fork, and import flows. */ export async function createAgentSessionRuntime( createRuntime: CreateAgentSessionRuntimeFactory, options: { cwd: string; agentDir: string; sessionManager: SessionManager; sessionStartEvent?: SessionStartEvent; }, ): Promise { assertSessionCwdExists(options.sessionManager, options.cwd); const result = await createRuntime(options); if (process.cwd() !== result.services.cwd) { process.chdir(result.services.cwd); } return new AgentSessionRuntime( result.session, result.services, createRuntime, result.diagnostics, result.modelFallbackMessage, ); } export { type AgentSessionRuntimeDiagnostic, type AgentSessionServices, type CreateAgentSessionFromServicesOptions, type CreateAgentSessionServicesOptions, createAgentSessionFromServices, createAgentSessionServices, } from "./agent-session-services.js";