From 3e34fda59ac114dd93ffe791c6e5561160b5b04b Mon Sep 17 00:00:00 2001 From: Hibryda Date: Wed, 11 Mar 2026 03:56:05 +0100 Subject: [PATCH] feat(providers): add Codex and Ollama provider runners with message adapters --- v2/package.json | 2 +- v2/sidecar/codex-runner.ts | 222 ++++++++++++++++++ v2/sidecar/ollama-runner.ts | 269 +++++++++++++++++++++ v2/src/App.svelte | 4 + v2/src/lib/adapters/codex-messages.ts | 297 ++++++++++++++++++++++++ v2/src/lib/adapters/message-adapters.ts | 6 +- v2/src/lib/adapters/ollama-messages.ts | 147 ++++++++++++ v2/src/lib/providers/codex.ts | 20 ++ v2/src/lib/providers/ollama.ts | 20 ++ 9 files changed, 985 insertions(+), 2 deletions(-) create mode 100644 v2/sidecar/codex-runner.ts create mode 100644 v2/sidecar/ollama-runner.ts create mode 100644 v2/src/lib/adapters/codex-messages.ts create mode 100644 v2/src/lib/adapters/ollama-messages.ts create mode 100644 v2/src/lib/providers/codex.ts create mode 100644 v2/src/lib/providers/ollama.ts diff --git a/v2/package.json b/v2/package.json index 1f1e5c1..be33103 100644 --- a/v2/package.json +++ b/v2/package.json @@ -14,7 +14,7 @@ "tauri:build": "cargo tauri build", "test": "vitest run", "test:e2e": "wdio run tests/e2e/wdio.conf.js", - "build:sidecar": "esbuild sidecar/claude-runner.ts --bundle --platform=node --format=esm --outfile=sidecar/dist/claude-runner.mjs" + "build:sidecar": "esbuild sidecar/claude-runner.ts --bundle --platform=node --format=esm --outfile=sidecar/dist/claude-runner.mjs && esbuild sidecar/codex-runner.ts --bundle --platform=node --format=esm --outfile=sidecar/dist/codex-runner.mjs && esbuild sidecar/ollama-runner.ts --bundle --platform=node --format=esm --outfile=sidecar/dist/ollama-runner.mjs" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^6.2.1", diff --git a/v2/sidecar/codex-runner.ts b/v2/sidecar/codex-runner.ts new file mode 100644 index 0000000..8086dce --- /dev/null +++ b/v2/sidecar/codex-runner.ts @@ -0,0 +1,222 @@ +// Codex Runner — Node.js sidecar entry point for OpenAI Codex provider +// Spawned by Rust SidecarManager, communicates via stdio NDJSON +// Uses @openai/codex-sdk for Codex session management + +import { stdin, stdout, stderr } from 'process'; +import { createInterface } from 'readline'; +import { execSync } from 'child_process'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +const rl = createInterface({ input: stdin }); + +const sessions = new Map(); + +function send(msg: Record) { + stdout.write(JSON.stringify(msg) + '\n'); +} + +function log(message: string) { + stderr.write(`[codex-sidecar] ${message}\n`); +} + +rl.on('line', (line: string) => { + try { + const msg = JSON.parse(line); + handleMessage(msg).catch((err: unknown) => { + log(`Unhandled error in message handler: ${err}`); + }); + } catch { + log(`Invalid JSON: ${line}`); + } +}); + +interface QueryMessage { + type: 'query'; + sessionId: string; + prompt: string; + cwd?: string; + maxTurns?: number; + resumeSessionId?: string; + permissionMode?: string; + systemPrompt?: string; + model?: string; + providerConfig?: Record; +} + +interface StopMessage { + type: 'stop'; + sessionId: string; +} + +async function handleMessage(msg: Record) { + switch (msg.type) { + case 'ping': + send({ type: 'pong' }); + break; + case 'query': + await handleQuery(msg as unknown as QueryMessage); + break; + case 'stop': + handleStop(msg as unknown as StopMessage); + break; + default: + send({ type: 'error', message: `Unknown message type: ${msg.type}` }); + } +} + +async function handleQuery(msg: QueryMessage) { + const { sessionId, prompt, cwd, maxTurns, resumeSessionId, permissionMode, model, providerConfig } = msg; + + if (sessions.has(sessionId)) { + send({ type: 'error', sessionId, message: 'Session already running' }); + return; + } + + log(`Starting Codex session ${sessionId}`); + + const controller = new AbortController(); + + // Strip CODEX*/OPENAI* env vars to prevent nesting issues + const cleanEnv: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (!key.startsWith('CODEX') && !key.startsWith('OPENAI')) { + cleanEnv[key] = value; + } + } + // Re-inject the API key + const apiKey = process.env.CODEX_API_KEY || process.env.OPENAI_API_KEY; + if (apiKey) { + cleanEnv['CODEX_API_KEY'] = apiKey; + } + + // Dynamically import SDK — fails gracefully if not installed + let Codex: any; + try { + const sdk = await import('@openai/codex-sdk'); + Codex = sdk.Codex ?? sdk.default; + } catch { + send({ type: 'agent_error', sessionId, message: 'Codex SDK not installed. Run: npm install @openai/codex-sdk' }); + return; + } + + if (!apiKey) { + send({ type: 'agent_error', sessionId, message: 'No API key. Set CODEX_API_KEY or OPENAI_API_KEY.' }); + return; + } + + try { + // Map permission mode to Codex sandbox/approval settings + const sandbox = mapSandboxMode(providerConfig?.sandbox as string | undefined, permissionMode); + const approvalPolicy = permissionMode === 'bypassPermissions' ? 'never' : 'on-request'; + + const codex = new Codex({ + env: cleanEnv as Record, + config: { + model: model ?? 'gpt-5.4', + approval_policy: approvalPolicy, + sandbox: sandbox, + }, + }); + + const threadOpts: Record = { + workingDirectory: cwd || process.cwd(), + }; + + const thread = resumeSessionId + ? codex.resumeThread(resumeSessionId) + : codex.startThread(threadOpts); + + sessions.set(sessionId, { controller }); + send({ type: 'agent_started', sessionId }); + + const streamResult = await thread.runStreamed(prompt); + + for await (const event of streamResult.events) { + if (controller.signal.aborted) break; + + // Forward raw Codex events — the message adapter parses them + send({ + type: 'agent_event', + sessionId, + event: event as Record, + }); + } + + sessions.delete(sessionId); + send({ + type: 'agent_stopped', + sessionId, + exitCode: 0, + signal: null, + }); + } catch (err: unknown) { + sessions.delete(sessionId); + const errMsg = err instanceof Error ? err.message : String(err); + + if (controller.signal.aborted) { + log(`Codex session ${sessionId} aborted`); + send({ + type: 'agent_stopped', + sessionId, + exitCode: null, + signal: 'SIGTERM', + }); + } else { + log(`Codex session ${sessionId} error: ${errMsg}`); + send({ + type: 'agent_error', + sessionId, + message: errMsg, + }); + } + } +} + +function handleStop(msg: StopMessage) { + const { sessionId } = msg; + const session = sessions.get(sessionId); + if (!session) { + send({ type: 'error', sessionId, message: 'Session not found' }); + return; + } + + log(`Stopping Codex session ${sessionId}`); + session.controller.abort(); +} + +function mapSandboxMode( + configSandbox: string | undefined, + permissionMode: string | undefined, +): string { + if (configSandbox) return configSandbox; + if (permissionMode === 'bypassPermissions') return 'danger-full-access'; + return 'workspace-write'; +} + +function findCodexCli(): string | undefined { + const candidates = [ + join(homedir(), '.local', 'bin', 'codex'), + '/usr/local/bin/codex', + '/usr/bin/codex', + ]; + for (const p of candidates) { + if (existsSync(p)) return p; + } + try { + return execSync('which codex 2>/dev/null || where codex 2>nul', { encoding: 'utf-8' }).trim().split('\n')[0]; + } catch { + return undefined; + } +} + +const codexPath = findCodexCli(); +if (codexPath) { + log(`Found Codex CLI at ${codexPath}`); +} else { + log('Codex CLI not found — will use SDK if available'); +} + +log('Codex sidecar started'); +send({ type: 'ready' }); diff --git a/v2/sidecar/ollama-runner.ts b/v2/sidecar/ollama-runner.ts new file mode 100644 index 0000000..8f3a9fb --- /dev/null +++ b/v2/sidecar/ollama-runner.ts @@ -0,0 +1,269 @@ +// Ollama Runner — Node.js sidecar entry point for local Ollama provider +// Spawned by Rust SidecarManager, communicates via stdio NDJSON +// Uses direct HTTP to Ollama REST API (no external dependencies) + +import { stdin, stdout, stderr } from 'process'; +import { createInterface } from 'readline'; + +const rl = createInterface({ input: stdin }); + +const sessions = new Map(); + +function send(msg: Record) { + stdout.write(JSON.stringify(msg) + '\n'); +} + +function log(message: string) { + stderr.write(`[ollama-sidecar] ${message}\n`); +} + +rl.on('line', (line: string) => { + try { + const msg = JSON.parse(line); + handleMessage(msg).catch((err: unknown) => { + log(`Unhandled error in message handler: ${err}`); + }); + } catch { + log(`Invalid JSON: ${line}`); + } +}); + +interface QueryMessage { + type: 'query'; + sessionId: string; + prompt: string; + cwd?: string; + model?: string; + systemPrompt?: string; + providerConfig?: Record; +} + +interface StopMessage { + type: 'stop'; + sessionId: string; +} + +async function handleMessage(msg: Record) { + switch (msg.type) { + case 'ping': + send({ type: 'pong' }); + break; + case 'query': + await handleQuery(msg as unknown as QueryMessage); + break; + case 'stop': + handleStop(msg as unknown as StopMessage); + break; + default: + send({ type: 'error', message: `Unknown message type: ${msg.type}` }); + } +} + +async function handleQuery(msg: QueryMessage) { + const { sessionId, prompt, cwd, model, systemPrompt, providerConfig } = msg; + + if (sessions.has(sessionId)) { + send({ type: 'error', sessionId, message: 'Session already running' }); + return; + } + + const ollamaHost = (providerConfig?.host as string) || process.env.OLLAMA_HOST || 'http://127.0.0.1:11434'; + const ollamaModel = model || 'qwen3:8b'; + const numCtx = (providerConfig?.num_ctx as number) || 32768; + const think = (providerConfig?.think as boolean) ?? false; + + log(`Starting Ollama session ${sessionId} with model ${ollamaModel}`); + + // Health check + try { + const healthRes = await fetch(`${ollamaHost}/api/version`); + if (!healthRes.ok) { + send({ type: 'agent_error', sessionId, message: `Ollama not reachable at ${ollamaHost} (HTTP ${healthRes.status})` }); + return; + } + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + send({ type: 'agent_error', sessionId, message: `Cannot connect to Ollama at ${ollamaHost}: ${errMsg}` }); + return; + } + + const controller = new AbortController(); + sessions.set(sessionId, { controller }); + send({ type: 'agent_started', sessionId }); + + // Emit init event + send({ + type: 'agent_event', + sessionId, + event: { + type: 'system', + subtype: 'init', + session_id: sessionId, + model: ollamaModel, + cwd: cwd || process.cwd(), + }, + }); + + // Build messages array + const messages: Array<{ role: string; content: string }> = []; + if (systemPrompt && typeof systemPrompt === 'string') { + messages.push({ role: 'system', content: systemPrompt }); + } + messages.push({ role: 'user', content: prompt }); + + try { + const res = await fetch(`${ollamaHost}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: ollamaModel, + messages, + stream: true, + options: { num_ctx: numCtx }, + think, + }), + signal: controller.signal, + }); + + if (!res.ok) { + const errBody = await res.text(); + let errMsg: string; + try { + const parsed = JSON.parse(errBody); + errMsg = parsed.error || errBody; + } catch { + errMsg = errBody; + } + send({ type: 'agent_error', sessionId, message: `Ollama error (${res.status}): ${errMsg}` }); + sessions.delete(sessionId); + return; + } + + if (!res.body) { + send({ type: 'agent_error', sessionId, message: 'No response body from Ollama' }); + sessions.delete(sessionId); + return; + } + + // Parse NDJSON stream + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + if (controller.signal.aborted) break; + + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + try { + const chunk = JSON.parse(trimmed) as Record; + + // Check for mid-stream error + if (typeof chunk.error === 'string') { + send({ + type: 'agent_event', + sessionId, + event: { type: 'error', message: chunk.error }, + }); + continue; + } + + // Forward as chunk event for the message adapter + send({ + type: 'agent_event', + sessionId, + event: { + type: 'chunk', + message: chunk.message, + done: chunk.done, + done_reason: chunk.done_reason, + model: chunk.model, + prompt_eval_count: chunk.prompt_eval_count, + eval_count: chunk.eval_count, + eval_duration: chunk.eval_duration, + total_duration: chunk.total_duration, + }, + }); + } catch { + log(`Failed to parse Ollama chunk: ${trimmed}`); + } + } + } + + // Process remaining buffer + if (buffer.trim()) { + try { + const chunk = JSON.parse(buffer.trim()) as Record; + send({ + type: 'agent_event', + sessionId, + event: { + type: 'chunk', + message: chunk.message, + done: chunk.done, + done_reason: chunk.done_reason, + model: chunk.model, + prompt_eval_count: chunk.prompt_eval_count, + eval_count: chunk.eval_count, + eval_duration: chunk.eval_duration, + total_duration: chunk.total_duration, + }, + }); + } catch { + log(`Failed to parse final Ollama buffer: ${buffer}`); + } + } + + sessions.delete(sessionId); + send({ + type: 'agent_stopped', + sessionId, + exitCode: 0, + signal: null, + }); + } catch (err: unknown) { + sessions.delete(sessionId); + const errMsg = err instanceof Error ? err.message : String(err); + + if (controller.signal.aborted) { + log(`Ollama session ${sessionId} aborted`); + send({ + type: 'agent_stopped', + sessionId, + exitCode: null, + signal: 'SIGTERM', + }); + } else { + log(`Ollama session ${sessionId} error: ${errMsg}`); + send({ + type: 'agent_error', + sessionId, + message: errMsg, + }); + } + } +} + +function handleStop(msg: StopMessage) { + const { sessionId } = msg; + const session = sessions.get(sessionId); + if (!session) { + send({ type: 'error', sessionId, message: 'Session not found' }); + return; + } + + log(`Stopping Ollama session ${sessionId}`); + session.controller.abort(); +} + +log('Ollama sidecar started'); +send({ type: 'ready' }); diff --git a/v2/src/App.svelte b/v2/src/App.svelte index 694819e..b3a1b51 100644 --- a/v2/src/App.svelte +++ b/v2/src/App.svelte @@ -7,6 +7,8 @@ import { startHealthTick, stopHealthTick, clearHealthTracking } from './lib/stores/health.svelte'; import { registerProvider } from './lib/providers/registry.svelte'; import { CLAUDE_PROVIDER } from './lib/providers/claude'; + import { CODEX_PROVIDER } from './lib/providers/codex'; + import { OLLAMA_PROVIDER } from './lib/providers/ollama'; import { loadWorkspace, getActiveTab, setActiveTab, setActiveProject, getEnabledProjects } from './lib/stores/workspace.svelte'; // Workspace components @@ -68,6 +70,8 @@ if (v) document.documentElement.style.setProperty('--project-max-aspect', v); }); registerProvider(CLAUDE_PROVIDER); + registerProvider(CODEX_PROVIDER); + registerProvider(OLLAMA_PROVIDER); startAgentDispatcher(); startHealthTick(); diff --git a/v2/src/lib/adapters/codex-messages.ts b/v2/src/lib/adapters/codex-messages.ts new file mode 100644 index 0000000..e235286 --- /dev/null +++ b/v2/src/lib/adapters/codex-messages.ts @@ -0,0 +1,297 @@ +// Codex Message Adapter — transforms Codex CLI NDJSON events to internal AgentMessage format +// Codex events: thread.started, turn.started, item.started/updated/completed, turn.completed/failed + +import type { + AgentMessage, + InitContent, + TextContent, + ThinkingContent, + ToolCallContent, + ToolResultContent, + StatusContent, + CostContent, + ErrorContent, +} from './claude-messages'; + +function str(v: unknown, fallback = ''): string { + return typeof v === 'string' ? v : fallback; +} + +function num(v: unknown, fallback = 0): number { + return typeof v === 'number' ? v : fallback; +} + +export function adaptCodexMessage(raw: Record): AgentMessage[] { + const timestamp = Date.now(); + const uuid = crypto.randomUUID(); + + switch (raw.type) { + case 'thread.started': + return [{ + id: uuid, + type: 'init', + content: { + sessionId: str(raw.thread_id), + model: '', + cwd: '', + tools: [], + } satisfies InitContent, + timestamp, + }]; + + case 'turn.started': + return [{ + id: uuid, + type: 'status', + content: { subtype: 'turn_started' } satisfies StatusContent, + timestamp, + }]; + + case 'turn.completed': + return adaptTurnCompleted(raw, uuid, timestamp); + + case 'turn.failed': + return [{ + id: uuid, + type: 'error', + content: { + message: str((raw.error as Record)?.message, 'Turn failed'), + } satisfies ErrorContent, + timestamp, + }]; + + case 'item.started': + case 'item.updated': + case 'item.completed': + return adaptItem(raw, uuid, timestamp); + + case 'error': + return [{ + id: uuid, + type: 'error', + content: { message: str(raw.message, 'Unknown error') } satisfies ErrorContent, + timestamp, + }]; + + default: + return [{ + id: uuid, + type: 'unknown', + content: raw, + timestamp, + }]; + } +} + +function adaptTurnCompleted( + raw: Record, + uuid: string, + timestamp: number, +): AgentMessage[] { + const usage = typeof raw.usage === 'object' && raw.usage !== null + ? raw.usage as Record + : {}; + + return [{ + id: uuid, + type: 'cost', + content: { + totalCostUsd: 0, + durationMs: 0, + inputTokens: num(usage.input_tokens), + outputTokens: num(usage.output_tokens), + numTurns: 1, + isError: false, + } satisfies CostContent, + timestamp, + }]; +} + +function adaptItem( + raw: Record, + uuid: string, + timestamp: number, +): AgentMessage[] { + const item = typeof raw.item === 'object' && raw.item !== null + ? raw.item as Record + : {}; + const itemType = str(item.type); + const eventType = str(raw.type); + + switch (itemType) { + case 'agent_message': + if (eventType !== 'item.completed') return []; + return [{ + id: uuid, + type: 'text', + content: { text: str(item.text) } satisfies TextContent, + timestamp, + }]; + + case 'reasoning': + if (eventType !== 'item.completed') return []; + return [{ + id: uuid, + type: 'thinking', + content: { text: str(item.text) } satisfies ThinkingContent, + timestamp, + }]; + + case 'command_execution': + return adaptCommandExecution(item, uuid, timestamp, eventType); + + case 'file_change': + return adaptFileChange(item, uuid, timestamp, eventType); + + case 'mcp_tool_call': + return adaptMcpToolCall(item, uuid, timestamp, eventType); + + case 'web_search': + if (eventType !== 'item.completed') return []; + return [{ + id: uuid, + type: 'tool_call', + content: { + toolUseId: str(item.id, uuid), + name: 'WebSearch', + input: { query: str(item.query) }, + } satisfies ToolCallContent, + timestamp, + }]; + + case 'error': + return [{ + id: uuid, + type: 'error', + content: { message: str(item.message, 'Item error') } satisfies ErrorContent, + timestamp, + }]; + + default: + return []; + } +} + +function adaptCommandExecution( + item: Record, + uuid: string, + timestamp: number, + eventType: string, +): AgentMessage[] { + const messages: AgentMessage[] = []; + const toolUseId = str(item.id, uuid); + + if (eventType === 'item.started' || eventType === 'item.completed') { + messages.push({ + id: `${uuid}-call`, + type: 'tool_call', + content: { + toolUseId, + name: 'Bash', + input: { command: str(item.command) }, + } satisfies ToolCallContent, + timestamp, + }); + } + + if (eventType === 'item.completed') { + messages.push({ + id: `${uuid}-result`, + type: 'tool_result', + content: { + toolUseId, + output: str(item.aggregated_output), + } satisfies ToolResultContent, + timestamp, + }); + } + + return messages; +} + +function adaptFileChange( + item: Record, + uuid: string, + timestamp: number, + eventType: string, +): AgentMessage[] { + if (eventType !== 'item.completed') return []; + + const changes = Array.isArray(item.changes) ? item.changes as Array> : []; + if (changes.length === 0) return []; + + const messages: AgentMessage[] = []; + for (const change of changes) { + const kind = str(change.kind); + const toolName = kind === 'delete' ? 'Bash' : kind === 'add' ? 'Write' : 'Edit'; + const toolUseId = `${uuid}-${str(change.path)}`; + + messages.push({ + id: `${toolUseId}-call`, + type: 'tool_call', + content: { + toolUseId, + name: toolName, + input: { file_path: str(change.path) }, + } satisfies ToolCallContent, + timestamp, + }); + + messages.push({ + id: `${toolUseId}-result`, + type: 'tool_result', + content: { + toolUseId, + output: `File ${kind}: ${str(change.path)}`, + } satisfies ToolResultContent, + timestamp, + }); + } + + return messages; +} + +function adaptMcpToolCall( + item: Record, + uuid: string, + timestamp: number, + eventType: string, +): AgentMessage[] { + const messages: AgentMessage[] = []; + const toolUseId = str(item.id, uuid); + const toolName = `${str(item.server)}:${str(item.tool)}`; + + if (eventType === 'item.started' || eventType === 'item.completed') { + messages.push({ + id: `${uuid}-call`, + type: 'tool_call', + content: { + toolUseId, + name: toolName, + input: item.arguments, + } satisfies ToolCallContent, + timestamp, + }); + } + + if (eventType === 'item.completed') { + const result = typeof item.result === 'object' && item.result !== null + ? item.result as Record + : undefined; + const error = typeof item.error === 'object' && item.error !== null + ? item.error as Record + : undefined; + + messages.push({ + id: `${uuid}-result`, + type: 'tool_result', + content: { + toolUseId, + output: error ? str(error.message, 'MCP tool error') : (result?.content ?? result?.structured_content ?? 'OK'), + } satisfies ToolResultContent, + timestamp, + }); + } + + return messages; +} diff --git a/v2/src/lib/adapters/message-adapters.ts b/v2/src/lib/adapters/message-adapters.ts index 5c42093..b816b91 100644 --- a/v2/src/lib/adapters/message-adapters.ts +++ b/v2/src/lib/adapters/message-adapters.ts @@ -4,6 +4,8 @@ import type { AgentMessage } from './claude-messages'; import type { ProviderId } from '../providers/types'; import { adaptSDKMessage } from './claude-messages'; +import { adaptCodexMessage } from './codex-messages'; +import { adaptOllamaMessage } from './ollama-messages'; /** Function signature for a provider message adapter */ export type MessageAdapter = (raw: Record) => AgentMessage[]; @@ -25,5 +27,7 @@ export function adaptMessage(providerId: ProviderId, raw: Record): AgentMessage[] { + const timestamp = Date.now(); + const uuid = crypto.randomUUID(); + + switch (raw.type) { + case 'system': + return adaptSystemEvent(raw, uuid, timestamp); + + case 'chunk': + return adaptChunk(raw, uuid, timestamp); + + case 'error': + return [{ + id: uuid, + type: 'error', + content: { message: str(raw.message, 'Ollama error') } satisfies ErrorContent, + timestamp, + }]; + + default: + return [{ + id: uuid, + type: 'unknown', + content: raw, + timestamp, + }]; + } +} + +function adaptSystemEvent( + raw: Record, + uuid: string, + timestamp: number, +): AgentMessage[] { + const subtype = str(raw.subtype); + + if (subtype === 'init') { + return [{ + id: uuid, + type: 'init', + content: { + sessionId: str(raw.session_id), + model: str(raw.model), + cwd: str(raw.cwd), + tools: [], + } satisfies InitContent, + timestamp, + }]; + } + + return [{ + id: uuid, + type: 'status', + content: { + subtype, + message: typeof raw.status === 'string' ? raw.status : undefined, + } satisfies StatusContent, + timestamp, + }]; +} + +function adaptChunk( + raw: Record, + uuid: string, + timestamp: number, +): AgentMessage[] { + const messages: AgentMessage[] = []; + const msg = typeof raw.message === 'object' && raw.message !== null + ? raw.message as Record + : {}; + const done = raw.done === true; + + // Thinking content (extended thinking from Qwen3 etc.) + const thinking = str(msg.thinking); + if (thinking) { + messages.push({ + id: `${uuid}-think`, + type: 'thinking', + content: { text: thinking } satisfies ThinkingContent, + timestamp, + }); + } + + // Text content + const text = str(msg.content); + if (text) { + messages.push({ + id: `${uuid}-text`, + type: 'text', + content: { text } satisfies TextContent, + timestamp, + }); + } + + // Final chunk with token counts + if (done) { + const doneReason = str(raw.done_reason); + const evalDuration = num(raw.eval_duration); + const durationMs = evalDuration > 0 ? Math.round(evalDuration / 1_000_000) : 0; + + messages.push({ + id: `${uuid}-cost`, + type: 'cost', + content: { + totalCostUsd: 0, + durationMs, + inputTokens: num(raw.prompt_eval_count), + outputTokens: num(raw.eval_count), + numTurns: 1, + isError: doneReason === 'error', + } satisfies CostContent, + timestamp, + }); + } + + return messages; +} diff --git a/v2/src/lib/providers/codex.ts b/v2/src/lib/providers/codex.ts new file mode 100644 index 0000000..f5b6d8b --- /dev/null +++ b/v2/src/lib/providers/codex.ts @@ -0,0 +1,20 @@ +// Codex Provider — metadata and capabilities for OpenAI Codex CLI + +import type { ProviderMeta } from './types'; + +export const CODEX_PROVIDER: ProviderMeta = { + id: 'codex', + name: 'Codex CLI', + description: 'OpenAI Codex CLI agent via SDK', + capabilities: { + hasProfiles: false, + hasSkills: false, + hasModelSelection: true, + hasSandbox: true, + supportsSubagents: false, + supportsCost: false, + supportsResume: true, + }, + sidecarRunner: 'codex-runner.mjs', + defaultModel: 'gpt-5.4', +}; diff --git a/v2/src/lib/providers/ollama.ts b/v2/src/lib/providers/ollama.ts new file mode 100644 index 0000000..9d58419 --- /dev/null +++ b/v2/src/lib/providers/ollama.ts @@ -0,0 +1,20 @@ +// Ollama Provider — metadata and capabilities for local Ollama models + +import type { ProviderMeta } from './types'; + +export const OLLAMA_PROVIDER: ProviderMeta = { + id: 'ollama', + name: 'Ollama', + description: 'Local Ollama models via REST API', + capabilities: { + hasProfiles: false, + hasSkills: false, + hasModelSelection: true, + hasSandbox: false, + supportsSubagents: false, + supportsCost: false, + supportsResume: false, + }, + sidecarRunner: 'ollama-runner.mjs', + defaultModel: 'qwen3:8b', +};