// @generated — DO NOT EDIT. Source: packages/ai/providers/codex-sdk.ts /** * Codex SDK provider — bridges Plannotator's AI layer with OpenAI's Codex agent. * * Uses @openai/codex-sdk to create sessions that can: * - Start fresh with Plannotator context as the system prompt * - Fake-fork from a parent session (fresh thread + preamble, no real history) * - Resume a previous thread by ID * - Stream text deltas back to the UI in real time * * Sessions default to read-only sandbox mode for safety in inline chat. */ import { buildSystemPrompt, buildEffectivePrompt } from "../context.ts"; import { BaseSession } from "../base-session.ts"; import type { AIProvider, AIProviderCapabilities, AISession, AIMessage, CreateSessionOptions, CodexSDKConfig, } from "../types.ts"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const PROVIDER_NAME = "codex-sdk"; const DEFAULT_MODEL = "gpt-5.4"; // --------------------------------------------------------------------------- // Provider // --------------------------------------------------------------------------- export class CodexSDKProvider implements AIProvider { readonly name = PROVIDER_NAME; readonly capabilities: AIProviderCapabilities = { fork: false, // No real fork — faked with fresh thread + preamble resume: true, streaming: true, tools: true, }; readonly models = [ { id: 'gpt-5.5', label: 'GPT-5.5' }, { id: 'gpt-5.4', label: 'GPT-5.4', default: true }, { id: 'gpt-5.4-mini', label: 'GPT-5.4 Mini' }, { id: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' }, { id: 'gpt-5.3-codex-spark', label: 'GPT-5.3 Codex Spark' }, { id: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' }, { id: 'gpt-5.2', label: 'GPT-5.2' }, ] as const; private config: CodexSDKConfig; constructor(config: CodexSDKConfig) { this.config = config; } async createSession(options: CreateSessionOptions): Promise { return new CodexSDKSession({ ...this.baseConfig(options), systemPrompt: buildSystemPrompt(options.context), cwd: options.cwd ?? this.config.cwd ?? process.cwd(), parentSessionId: null, }); } async forkSession(_options: CreateSessionOptions): Promise { throw new Error( "Codex does not support session forking. " + "The endpoint layer should fall back to createSession()." ); } async resumeSession(sessionId: string): Promise { return new CodexSDKSession({ ...this.baseConfig(), systemPrompt: null, cwd: this.config.cwd ?? process.cwd(), parentSessionId: null, resumeThreadId: sessionId, }); } dispose(): void { // No persistent resources to clean up } private baseConfig(options?: CreateSessionOptions) { return { model: options?.model ?? this.config.model ?? DEFAULT_MODEL, maxTurns: options?.maxTurns ?? 99, sandboxMode: this.config.sandboxMode ?? "read-only" as const, codexExecutablePath: this.config.codexExecutablePath, reasoningEffort: options?.reasoningEffort, }; } } // --------------------------------------------------------------------------- // SDK import cache — resolve once, reuse across all sessions // --------------------------------------------------------------------------- // biome-ignore lint/suspicious/noExplicitAny: SDK type not available at compile time let CodexClass: any = null; async function getCodexClass() { if (!CodexClass) { // biome-ignore lint/suspicious/noExplicitAny: SDK exports vary between versions const mod = await import("@openai/codex-sdk") as any; CodexClass = mod.default ?? mod.Codex; } return CodexClass; } // --------------------------------------------------------------------------- // Session // --------------------------------------------------------------------------- interface SessionConfig { systemPrompt: string | null; model: string; maxTurns: number; sandboxMode: "read-only" | "workspace-write" | "danger-full-access"; cwd: string; parentSessionId: string | null; resumeThreadId?: string; codexExecutablePath?: string; reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh"; } class CodexSDKSession extends BaseSession { private config: SessionConfig; // biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time private _codexInstance: any = null; // biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time private _thread: any = null; /** Tracks cumulative text length per item for delta extraction. */ private _itemTextOffsets = new Map(); constructor(config: SessionConfig) { super({ parentSessionId: config.parentSessionId, initialId: config.resumeThreadId, }); this.config = config; // If resuming, treat the thread ID as already resolved if (config.resumeThreadId) { this._resolvedId = config.resumeThreadId; } } async *query(prompt: string): AsyncIterable { const started = this.startQuery(); if (!started) { yield BaseSession.BUSY_ERROR; return; } const { gen, signal } = started; this._itemTextOffsets.clear(); try { const Codex = await getCodexClass(); // Lazy-create the Codex instance if (!this._codexInstance) { this._codexInstance = new Codex({ ...(this.config.codexExecutablePath && { codexPathOverride: this.config.codexExecutablePath }), }); } // Lazy-create or resume the thread if (!this._thread) { if (this.config.resumeThreadId) { this._thread = this._codexInstance.resumeThread(this.config.resumeThreadId, { model: this.config.model, workingDirectory: this.config.cwd, sandboxMode: this.config.sandboxMode, ...(this.config.reasoningEffort && { modelReasoningEffort: this.config.reasoningEffort }), }); } else { this._thread = this._codexInstance.startThread({ model: this.config.model, workingDirectory: this.config.cwd, sandboxMode: this.config.sandboxMode, ...(this.config.reasoningEffort && { modelReasoningEffort: this.config.reasoningEffort }), }); } } const effectivePrompt = buildEffectivePrompt( prompt, this.config.systemPrompt, this._firstQuerySent, ); const streamed = await this._thread.runStreamed(effectivePrompt, { signal, }); this._firstQuerySent = true; let turnFailed = false; for await (const event of streamed.events) { // ID resolution from thread.started if ( !this._resolvedId && event.type === "thread.started" && typeof event.thread_id === "string" ) { this.resolveId(event.thread_id); } if (event.type === "turn.failed") { turnFailed = true; } const mapped = mapCodexEvent(event, this._itemTextOffsets); for (const msg of mapped) { yield msg; } } // Emit synthetic result after stream ends if (!turnFailed) { yield { type: "result", sessionId: this.id, success: true, }; } } catch (err) { yield { type: "error", error: err instanceof Error ? err.message : String(err), code: "provider_error", }; } finally { this.endQuery(gen); } } } // --------------------------------------------------------------------------- // Event mapping // --------------------------------------------------------------------------- /** * Map a Codex SDK ThreadEvent to one or more AIMessages. * * The itemTextOffsets map tracks cumulative text length per item ID * so we can extract true deltas from the cumulative text in item.updated events. */ function mapCodexEvent( event: Record, itemTextOffsets: Map, ): AIMessage[] { const eventType = event.type as string; switch (eventType) { case "thread.started": case "turn.started": return []; case "turn.completed": return []; case "turn.failed": { const error = event.error as Record | undefined; return [{ type: "error", error: (error?.message as string) ?? "Turn failed", code: "turn_failed", }]; } case "error": return [{ type: "error", error: (event.message as string) ?? "Unknown error", code: "codex_error", }]; case "item.started": case "item.updated": case "item.completed": return mapCodexItem(event, itemTextOffsets); default: return [{ type: "unknown", raw: event }]; } } /** * Map item-level events to AIMessages. */ function mapCodexItem( event: Record, itemTextOffsets: Map, ): AIMessage[] { const item = event.item as Record; if (!item) return [{ type: "unknown", raw: event }]; const eventType = event.type as string; const itemType = item.type as string; const itemId = (item.id as string) ?? ""; const isStarted = eventType === "item.started"; const isCompleted = eventType === "item.completed"; switch (itemType) { case "agent_message": { const text = (item.text as string) ?? ""; if (isStarted) { // Reset offset tracking for this item itemTextOffsets.set(itemId, 0); return []; } if (isCompleted) { // Emit final complete text itemTextOffsets.delete(itemId); return text ? [{ type: "text", text }] : []; } // item.updated — extract delta from cumulative text const prevOffset = itemTextOffsets.get(itemId) ?? 0; if (text.length > prevOffset) { const delta = text.slice(prevOffset); itemTextOffsets.set(itemId, text.length); return [{ type: "text_delta", delta }]; } return []; } case "command_execution": { const messages: AIMessage[] = []; if (isStarted) { messages.push({ type: "tool_use", toolName: "Bash", toolInput: { command: item.command as string }, toolUseId: itemId, }); } if (isCompleted) { const output = (item.aggregated_output as string) ?? ""; const exitCode = item.exit_code as number | undefined; messages.push({ type: "tool_result", toolUseId: itemId, result: exitCode != null ? `${output}\n[exit code: ${exitCode}]` : output, }); } return messages; } case "file_change": { const changes = item.changes as Array<{ path: string; kind: string }> | undefined; if (isStarted || isCompleted) { return [{ type: "tool_use", toolName: "FileChange", toolInput: { changes: changes ?? [] }, toolUseId: itemId, }]; } return []; } case "mcp_tool_call": { const messages: AIMessage[] = []; if (isStarted) { messages.push({ type: "tool_use", toolName: `${item.server as string}/${item.tool as string}`, toolInput: (item.arguments as Record) ?? {}, toolUseId: itemId, }); } if (isCompleted) { if (item.result != null) { messages.push({ type: "tool_result", toolUseId: itemId, result: typeof item.result === "string" ? item.result : JSON.stringify(item.result), }); } if (item.error) { const err = item.error as Record; messages.push({ type: "error", error: (err.message as string) ?? "MCP tool call failed", code: "mcp_error", }); } } return messages; } case "error": return [{ type: "error", error: (item.message as string) ?? "Unknown error", }]; case "reasoning": case "web_search": case "todo_list": return [{ type: "unknown", raw: { eventType, item } }]; default: return [{ type: "unknown", raw: { eventType, item } }]; } } // --------------------------------------------------------------------------- // Exported for testing // --------------------------------------------------------------------------- export { mapCodexEvent, mapCodexItem }; // --------------------------------------------------------------------------- // Factory registration // --------------------------------------------------------------------------- import { registerProviderFactory } from "../provider.ts"; registerProviderFactory( PROVIDER_NAME, async (config) => new CodexSDKProvider(config as CodexSDKConfig) );