/** * Pi session backend — wraps pi's SDK AgentSession for in-process execution. * * Events flow through the translatePiEvent pipeline. The AgentEvent shapes * from subscribe() match the ServerMessage contract consumed by iOS. */ import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; import { basename, extname, join, resolve } from "node:path"; import { createAgentSession, createBashToolDefinition, createReadToolDefinition, createWriteToolDefinition, createEditToolDefinition, type AgentSession, type AgentSessionEvent, type ExtensionFactory, type ExtensionUIDialogOptions, type ExtensionUIContext, SessionManager as PiSessionManager, DefaultResourceLoader, AuthStorage, ModelRegistry, SettingsManager, getAgentDir, } from "@mariozechner/pi-coding-agent"; import type { ImageContent } from "@mariozechner/pi-ai"; import type { GateServer } from "./gate.js"; import type { ExtensionErrorEvent, ExtensionUIRequestEvent, PiStateSnapshot, SessionBackendEvent, } from "./pi-events.js"; import { isManagedExtensionName } from "../extensions/first-party.js"; import type { ServerMetricCollector } from "./server-metric-collector.js"; import type { Storage } from "./storage.js"; import type { Session, Workspace } from "./types.js"; /** Parse an oppi model string like "anthropic/claude-sonnet-4-20250514" into { provider, model }. */ function parseModelId(modelId: string): { provider: string; model: string } | null { const slash = modelId.indexOf("/"); if (slash <= 0) return null; return { provider: modelId.substring(0, slash), model: modelId.substring(slash + 1) }; } function resolveRegistryModel( modelRegistry: Pick, modelId: string, ): ReturnType { const parsed = parseModelId(modelId); if (!parsed) { return undefined; } return modelRegistry.find(parsed.provider, parsed.model); } function getExtensionName(ext: { path: string; resolvedPath: string }): string { const file = basename(ext.resolvedPath || ext.path); const suffix = extname(file); return suffix ? file.slice(0, -suffix.length) : file; } /** * Resolve workspace host mount into an absolute SDK cwd. * * Workspace hostMount is stored in display form (commonly "~/..."). * Node path APIs do not expand "~" and will treat it as a relative path, * producing cwd values like "/~/workspace/...". Normalize here * before passing cwd into SDK components. */ export function resolveSdkSessionCwd(workspace?: Workspace): string { const rawHostMount = workspace?.hostMount?.trim(); if (!rawHostMount) { if (workspace?.runtime === "sandbox") { // Auto-create a dedicated sandbox directory. Permanent, per-workspace. // Slug the name to a safe directory name, fall back to id. const slug = (workspace.name || workspace.id) .toLowerCase() .replace(/[^a-z0-9-_]/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, "") || workspace.id; const sandboxDir = join(homedir(), "sandbox", slug); mkdirSync(sandboxDir, { recursive: true }); return sandboxDir; } return homedir(); } const expanded = rawHostMount === "~" || rawHostMount.startsWith("~/") ? rawHostMount.replace(/^~(?=\/|$)/, homedir()) : rawHostMount; return resolve(expanded); } export interface SdkBackendConfig { session: Session; workspace?: Workspace; /** Called for SDK agent events and extension callback events. */ onEvent: (event: SessionBackendEvent) => void; /** Called when the session ends. */ onEnd: (reason: string) => void; /** Gate server for permission checks. */ gate?: GateServer; /** Workspace ID for gate guard registration. */ workspaceId?: string; /** Whether to enable the permission gate. Default: true if gate is provided. */ permissionGate?: boolean; /** Resolved skill directory paths for this workspace. */ skillPaths?: string[]; /** Storage for server-managed extensions. */ storage?: Storage; /** Additional extension factories injected for this session. */ extraExtensionFactories?: ExtensionFactory[]; /** Operational metrics collector for SDK timing. */ metrics?: ServerMetricCollector; } interface ExtensionUIResponsePayload { id: string; value?: string; confirmed?: boolean; cancelled?: boolean; } interface PendingExtensionUIResponse { resolve: (response: ExtensionUIResponsePayload) => void; cancel: () => void; } /** * Wraps a pi AgentSession for use by SessionManager. * * Lifecycle: * const backend = await SdkBackend.create(config); * backend.prompt("hello"); * backend.abort(); * backend.dispose(); */ export class SdkBackend { private static readonly DEFAULT_STEERING_MODE = "all" as const; private static readonly DEFAULT_FOLLOW_UP_MODE = "one-at-a-time" as const; // eslint-disable-next-line @typescript-eslint/no-explicit-any private static _gondolinManager: any; private piSession: AgentSession; private unsub: () => void; private readonly emitEvent: (event: SessionBackendEvent) => void; private readonly modelRegistry: ModelRegistry; private readonly pendingExtensionResponses = new Map(); private disposed = false; private constructor( piSession: AgentSession, unsub: () => void, emitEvent: (event: SessionBackendEvent) => void, modelRegistry: ModelRegistry, ) { this.piSession = piSession; this.unsub = unsub; this.emitEvent = emitEvent; this.modelRegistry = modelRegistry; } static async create(config: SdkBackendConfig): Promise { const createStartMs = Date.now(); const { session, workspace, onEvent, onEnd: _onEnd } = config; const cwd = resolveSdkSessionCwd(workspace); const agentDir = getAgentDir(); const authStorage = AuthStorage.create(join(agentDir, "auth.json")); const modelRegistry = ModelRegistry.create(authStorage, join(agentDir, "models.json")); const settingsManager = SettingsManager.create(cwd, agentDir); // Resolve the model from the session's model ID. // Use ModelRegistry so custom providers/models (e.g. LM Studio) work. const model = session.model ? resolveRegistryModel(modelRegistry, session.model) : undefined; if (session.model && !model) { console.warn("[sdk] Failed to resolve model, using default", { model: session.model, }); } // Use file-based session manager for persistence const piSessionFile = (session as { piSessionFile?: string }).piSessionFile; const piSessionManager = piSessionFile ? PiSessionManager.open(piSessionFile) : PiSessionManager.create(cwd); // Build extension factories for in-process tools const extensionFactories: ExtensionFactory[] = []; const useGate = config.gate && config.permissionGate !== false; if (useGate && config.gate) { extensionFactories.push( createPermissionGateFactory(config.gate, session.id, config.workspaceId || "", cwd), ); } if (config.extraExtensionFactories) { extensionFactories.push(...config.extraExtensionFactories); } // Resource loader — suppress auto-discovery, load only what we need. // Extension factories (permission gate) are injected here. // Pi's auto-discovered permission-gate extension is filtered out since // oppi has its own policy engine (GateServer). Without this, both gates // run and the pi extension blocks commands it considers "dangerous" with // no UI to approve them (ctx.hasUI is false in oppi sessions). const workspaceSystemPromptMode = workspace?.systemPromptMode ?? "append"; const loader = new DefaultResourceLoader({ cwd, agentDir, settingsManager, additionalExtensionPaths: [], additionalSkillPaths: config.skillPaths ?? [], noSkills: true, noPromptTemplates: true, noThemes: true, extensionFactories, systemPrompt: workspaceSystemPromptMode === "replace" ? workspace?.systemPrompt : undefined, appendSystemPrompt: workspaceSystemPromptMode === "append" ? workspace?.systemPrompt : undefined, extensionsOverride: (base) => { // 1. Filter out extensions managed directly by oppi-server. // ask and spawn_agent are injected as first-party factory extensions, // and permission-gate is replaced by oppi's own policy engine. let filtered = base.extensions.filter( (ext) => !isManagedExtensionName(getExtensionName(ext)), ); // 2. If the workspace specifies an extensions allowlist, that allowlist is // authoritative. Always keep inline factory extensions (path "") // because they're injected programmatically by the server. const allowedNames = workspace?.extensions; if (allowedNames !== undefined) { const allowed = new Set(allowedNames); filtered = filtered.filter((ext) => { if (ext.path.startsWith(" = {}; try { const allCreds = authStorage.getAll(); for (const [provider, cred] of Object.entries(allCreds)) { if (cred.type === "api_key" && cred.key) { secrets[`${provider.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`] = { value: cred.key, headerName: "Authorization", }; } } } catch { // Auth extraction failed — proceed without secrets } // Mount specific subdirectories read-only so the agent can read // SKILL.md files and extensions at the paths referenced in the system prompt. // Do NOT mount agentDir itself — it contains auth.json (API keys). const readonlyMounts: string[] = []; if (config.skillPaths) { readonlyMounts.push(...config.skillPaths); } // Mount only safe subdirectories, not the whole agentDir. // skills/ is already covered by skillPaths above. // extensions/ is needed for loaded extensions to resolve their own files. const extensionsDir = join(agentDir, "extensions"); if (existsSync(extensionsDir)) { readonlyMounts.push(extensionsDir); } const vm = await manager.ensureWorkspaceVm(workspace, cwd, secrets, readonlyMounts); sandboxTools = [ createReadToolDefinition(cwd, { operations: createGondolinReadOps(vm, cwd) }), createBashToolDefinition(cwd, { operations: createGondolinBashOps(vm, cwd) }), createEditToolDefinition(cwd, { operations: createGondolinEditOps(vm, cwd) }), createWriteToolDefinition(cwd, { operations: createGondolinWriteOps(vm, cwd) }), ]; console.log("[sdk] Sandbox VM ready", { workspaceId: workspace.id || "unknown" }); } const { session: piSession } = await createAgentSession({ cwd, agentDir, authStorage, modelRegistry, model, thinkingLevel: (session.thinkingLevel as "off" | "minimal" | "low" | "medium" | "high" | "xhigh") || "medium", sessionManager: piSessionManager, settingsManager, resourceLoader: loader, // Sandbox: disable all built-in tools (tools: []) and inject VM-backed // implementations as customTools. This ensures bash/read/write/edit execute // inside the Gondolin VM, not on the host. ...(sandboxTools ? { tools: [], customTools: sandboxTools } : {}), }); SdkBackend.applyDefaultQueueModes(piSession); // Subscribe to agent events — forward everything to the translation layer. const unsub = piSession.subscribe((event: AgentSessionEvent) => { onEvent(event); }); const backend = new SdkBackend(piSession, unsub, onEvent, modelRegistry); const preBindMs = Date.now() - createStartMs; config.metrics?.record("server.session_create_sdk_ms", preBindMs); await piSession.bindExtensions({ uiContext: backend.createExtensionUIContext(), onError: (error) => { const event: ExtensionErrorEvent = { type: "extension_error", extensionPath: error.extensionPath, event: error.event, error: error.error, }; onEvent(event); }, }); const totalMs = Date.now() - createStartMs; const bindMs = totalMs - preBindMs; config.metrics?.record("server.session_create_bind_ms", bindMs); console.log("[sdk] Session created", { model: piSession.model?.id ?? piSession.model?.name, thinking: piSession.thinkingLevel, setupMs: preBindMs, bindExtensionMs: bindMs, totalMs, }); return backend; } get session(): AgentSession { return this.piSession; } private static applyDefaultQueueModes( session: Pick, ): void { session.setSteeringMode(SdkBackend.DEFAULT_STEERING_MODE); session.setFollowUpMode(SdkBackend.DEFAULT_FOLLOW_UP_MODE); } private emitExtensionUIRequest(request: Omit): void { this.emitEvent({ type: "extension_ui_request", ...request, }); } private createDialogPromise( opts: ExtensionUIDialogOptions | undefined, defaultValue: T, request: Omit, parseResponse: (response: ExtensionUIResponsePayload) => T, ): Promise { if (this.disposed || opts?.signal?.aborted) { return Promise.resolve(defaultValue); } const id = randomUUID(); return new Promise((resolve) => { let timeoutId: NodeJS.Timeout | undefined; const cleanup = (): void => { if (timeoutId) { clearTimeout(timeoutId); } opts?.signal?.removeEventListener("abort", onAbort); this.pendingExtensionResponses.delete(id); }; const cancel = (): void => { cleanup(); resolve(defaultValue); }; const onAbort = (): void => { cancel(); }; opts?.signal?.addEventListener("abort", onAbort, { once: true }); if (opts?.timeout) { timeoutId = setTimeout(() => { cancel(); }, opts.timeout); } this.pendingExtensionResponses.set(id, { resolve: (response) => { cleanup(); resolve(parseResponse(response)); }, cancel, }); this.emitExtensionUIRequest({ id, ...request, timeout: opts?.timeout, }); }); } private createExtensionUIContext(): ExtensionUIContext { return { select: (title, options, opts) => this.createDialogPromise( opts, undefined, { method: "select", title, options }, (response) => (response.cancelled ? undefined : response.value), ), confirm: (title, message, opts) => this.createDialogPromise(opts, false, { method: "confirm", title, message }, (response) => response.cancelled ? false : (response.confirmed ?? false), ), input: (title, placeholder, opts) => this.createDialogPromise( opts, undefined, { method: "input", title, placeholder }, (response) => (response.cancelled ? undefined : response.value), ), notify: (message, type) => { this.emitExtensionUIRequest({ id: randomUUID(), method: "notify", message, notifyType: type, }); }, onTerminalInput: () => () => { // Raw terminal input is not supported in Oppi server sessions. }, setStatus: (key, text) => { this.emitExtensionUIRequest({ id: randomUUID(), method: "setStatus", statusKey: key, statusText: text, }); }, setWorkingMessage: (_message) => { // Working message requires TUI access; unsupported in Oppi sessions. }, setWidget: (key, content, options) => { if (content === undefined || Array.isArray(content)) { this.emitExtensionUIRequest({ id: randomUUID(), method: "setWidget", widgetKey: key, widgetLines: content, widgetPlacement: options?.placement, }); } }, setFooter: (_factory) => { // Custom footer requires TUI access; unsupported in Oppi sessions. }, setHeader: (_factory) => { // Custom header requires TUI access; unsupported in Oppi sessions. }, setTitle: (title) => { this.emitExtensionUIRequest({ id: randomUUID(), method: "setTitle", title, }); }, custom: async () => { return undefined; }, pasteToEditor: (text) => { this.emitExtensionUIRequest({ id: randomUUID(), method: "set_editor_text", text, }); }, setEditorText: (text) => { this.emitExtensionUIRequest({ id: randomUUID(), method: "set_editor_text", text, }); }, getEditorText: () => { return ""; }, editor: (title, prefill) => this.createDialogPromise( undefined, undefined, { method: "editor", title, prefill }, (response) => (response.cancelled ? undefined : response.value), ), setEditorComponent: (_factory) => { // Custom editor components require TUI access; unsupported in Oppi sessions. }, get theme() { return {} as ExtensionUIContext["theme"]; }, getAllThemes: () => [], getTheme: (_name) => undefined, setTheme: (_theme) => ({ success: false, error: "Theme switching not supported in Oppi sessions", }), getToolsExpanded: () => false, setToolsExpanded: (_expanded) => { // Tool expansion requires TUI access; unsupported in Oppi sessions. }, setHiddenThinkingLabel: (_label) => { // Thinking label customization requires TUI; unsupported in Oppi sessions. }, } as ExtensionUIContext; } respondToExtensionUIRequest(response: ExtensionUIResponsePayload): boolean { const pending = this.pendingExtensionResponses.get(response.id); if (!pending) { return false; } pending.resolve(response); return true; } // ─── Commands ─── /** Send a prompt. Fire-and-forget — events come via subscribe. */ prompt( message: string, opts?: { images?: Array<{ type: "image"; data: string; mimeType: string }>; streamingBehavior?: "steer" | "followUp"; }, ): void { if (this.disposed) return; const images: ImageContent[] | undefined = opts?.images?.map((img) => ({ type: "image" as const, data: img.data, mimeType: img.mimeType, })); this.piSession .prompt(message, { images, streamingBehavior: opts?.streamingBehavior, }) .catch((err) => { const errorMessage = err instanceof Error ? err.message : String(err); console.error("[sdk] prompt error", { error: err }); this.emitEvent({ type: "prompt_error", error: errorMessage }); }); } async abort(): Promise { if (this.disposed) return; await this.piSession.abort(); } async setModel(modelId: string): Promise<{ success: boolean; provider?: string; id?: string; name?: string; error?: string; }> { const parsed = parseModelId(modelId); if (!parsed) { return { success: false, error: `Invalid model ID: ${modelId}` }; } const model = this.modelRegistry.find(parsed.provider, parsed.model); if (!model) { return { success: false, error: `Unknown model: ${modelId}` }; } try { await this.piSession.setModel(model); const activeModel = this.piSession.model; return { success: true, provider: activeModel?.provider, id: activeModel?.id, name: activeModel?.name, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { success: false, error: message }; } } /** Full state snapshot for client command responses. */ getStateSnapshot(): PiStateSnapshot { const m = this.piSession.model; return { sessionFile: this.piSession.sessionFile, sessionId: this.piSession.sessionId, sessionName: this.piSession.sessionName, model: m ? { provider: m.provider, id: m.id, name: m.name } : undefined, thinkingLevel: this.piSession.thinkingLevel, isStreaming: this.piSession.isStreaming, autoCompaction: this.piSession.autoCompactionEnabled, }; } get isDisposed(): boolean { return this.disposed; } get isStreaming(): boolean { return this.piSession.isStreaming; } get sessionFile(): string | undefined { return this.piSession.sessionFile; } get sessionId(): string { return this.piSession.sessionId; } dispose(): void { if (this.disposed) return; this.disposed = true; for (const pending of this.pendingExtensionResponses.values()) { pending.cancel(); } this.pendingExtensionResponses.clear(); this.unsub(); this.piSession.dispose(); } } // ─── In-Process Permission Gate Extension Factory ─── /** * Create an ExtensionFactory that gates tool calls through GateServer. * Runs in-process — every tool call is evaluated by the policy engine. */ function createPermissionGateFactory( gate: GateServer, sessionId: string, workspaceId: string, sessionCwd: string, ): ExtensionFactory { return (extensionApi: unknown) => { const pi = extensionApi as { on( event: "tool_call", handler: (event: { toolName: string; toolCallId: string; input: Record; }) => Promise<{ block: true; reason: string } | void>, ): void; on(event: "session_shutdown", handler: () => void): void; }; // Register guard for this session. gate.createGuard(sessionId, workspaceId); console.log("[sdk-gate] Virtual guard registered", { sessionId, workspaceId, }); // Gate every tool call through the policy engine pi.on( "tool_call", async (event: { toolName: string; toolCallId: string; input: Record }) => { const result = await gate.checkToolCall(sessionId, { tool: event.toolName, input: event.input, toolCallId: event.toolCallId, sessionCwd, }); if (result.action === "deny") { return { block: true, reason: result.reason || "Denied by permission gate" }; } // Allow — return void, tool executes normally }, ); // Clean up on shutdown pi.on("session_shutdown", () => { gate.destroySessionGuard(sessionId); console.log("[sdk-gate] Guard destroyed", { sessionId, }); }); }; }