From 052cc78c0861d2080f32ba247822e262c9618458 Mon Sep 17 00:00:00 2001 From: charpty <8869963+charpty@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:39:15 +0800 Subject: [PATCH 1/3] feat: add Codex runtime support --- CLAUDE.md | 9 +- docs/troubleshoot.md | 17 + server/src/auto-name.ts | 2 +- server/src/bootstrap.ts | 35 +- server/src/codex-cli.ts | 145 ++++++++ server/src/config.ts | 93 +++-- server/src/github.ts | 29 +- server/src/index.ts | 94 ++++- server/src/podman.ts | 7 +- server/src/presets.ts | 8 +- server/src/session.ts | 360 +++++++++++++++++-- server/test/codex-cli.test.ts | 108 ++++++ web/src/api.ts | 16 +- web/src/components/chat/ModelSelector.tsx | 3 +- web/src/components/dialog/AdminDialog.tsx | 38 +- web/src/components/settings/PresetsPanel.tsx | 25 +- web/src/pages/SettingsPage.tsx | 39 +- web/src/useLoopRuntime.tsx | 2 + 18 files changed, 910 insertions(+), 120 deletions(-) create mode 100644 server/src/codex-cli.ts create mode 100644 server/test/codex-cli.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index d2995ba5..842fe3f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,12 +5,19 @@ Self-hosted AI workspace built around context management. Monorepo with two work ## Tech stack - **Runtime**: Bun (server + build tooling) -- **Server**: Hono, TypeScript, Claude Agent SDK, podman (sandbox containers) +- **Server**: Hono, TypeScript, Claude Agent SDK, Codex CLI runtime, podman (sandbox containers) - **Web**: React 19, Vite 8, Tailwind CSS v4, Zustand, assistant-ui, xterm.js, CodeMirror, Milkdown - **Infra**: Docker (oven/bun base), rootless podman inside container - **Tests**: Playwright (e2e + dogfood) - **Rust**: Two small binaries in `server/src/serve-rs` and `server/src/port-proxy-rs`, built as part of `web build` +## Agent runtimes + +- Providers declare `runtime: "claude" | "codex"`; legacy provider configs default to Claude. +- Claude runs through the Agent SDK inside podman. Codex runs through the host CLI and must not initialize Claude settings, plugins, MCP servers, containers, or the Anthropic egress gateway. +- Keep Codex command construction, environment mapping, and JSONL parsing in `server/src/codex-cli.ts`. Session code owns lifecycle and UI event translation only. +- Add focused adapter tests when Codex CLI arguments or event parsing changes. Preserve the existing Claude path unless a migration is explicitly required. + ## Development ```bash diff --git a/docs/troubleshoot.md b/docs/troubleshoot.md index 2e98fd7d..27b4bb06 100644 --- a/docs/troubleshoot.md +++ b/docs/troubleshoot.md @@ -2,6 +2,23 @@ If chat doesn't work or the UI shows red errors, walk this list top-to-bottom. Most issues land in §1 or §2. +## Codex exits with code 1 + +Loopat records Codex CLI diagnostics in the loop's `stderr.log`. Codex API +failures arrive as JSONL events on stdout; Loopat extracts `error` and +`turn.failed` events and shows their message in the chat. + +If the selected model requires a newer Codex CLI, upgrade the CLI or point +Loopat at a newer binary before restarting: + +```sh +LOOPAT_CODEX_BIN=/path/to/codex bun run dev +``` + +On macOS, ChatGPT may include a newer CLI at +`/Applications/ChatGPT.app/Contents/Resources/codex`. Confirm it with +`codex --version` before configuring the path. + ## 0. The bootstrap banner is the first signal Whatever's wrong, look at the banner `bun run dev` prints first: diff --git a/server/src/auto-name.ts b/server/src/auto-name.ts index b7c0a6c8..f4b5caeb 100644 --- a/server/src/auto-name.ts +++ b/server/src/auto-name.ts @@ -82,7 +82,7 @@ async function resolveProvidersForLoop(meta: { createdBy: string; config?: { def if (seen.has(name)) continue seen.add(name) const p = pCfg.providers[name] ?? wCfg.providers?.[name] - if (p && p.apiKey) result.push(p) + if (p && p.apiKey && p.runtime !== "codex") result.push(p) } return result } diff --git a/server/src/bootstrap.ts b/server/src/bootstrap.ts index 7ce299eb..812e69ce 100644 --- a/server/src/bootstrap.ts +++ b/server/src/bootstrap.ts @@ -17,8 +17,9 @@ import { workspaceTeamClaudeMdPath, } from "./paths" import { listUsers } from "./auth" +import { codexBinary } from "./codex-cli" -type Check = { ok: boolean; label: string; hint?: string } +type Check = { ok: boolean; label: string; hint?: string; required?: boolean } /** The host to print in the "open …" url. HOST=0.0.0.0/:: means "all * interfaces" — localhost works locally but isn't reachable from other @@ -84,6 +85,20 @@ function checkClaudeBinary(): Check { } } +function checkCodexCli(): Check { + const codexBin = codexBinary() + try { + const out = execFileSync(codexBin, ["--version"], { stdio: "pipe" }).toString().trim() + return { ok: true, label: `codex cli: ${out || "available"}` } + } catch { + return { + ok: false, + label: "codex cli", + hint: "install Codex CLI or run `codex login` on the host if you want the Codex runtime", + } + } +} + function checkGitCrypt(): Check { try { const out = execFileSync("git-crypt", ["--version"], { stdio: "pipe" }).toString().trim() @@ -126,6 +141,15 @@ export async function printBootstrapBanner(cfg: WorkspaceConfig) { // notes is declared inside the knowledge repo's .loopat/config.json. The repo // roster is per-user (personal config), so the workspace banner can't list it. const kcfg = await loadKnowledgeConfig() + const podman = checkPodman() + const claude = checkClaudeBinary() + const codex = checkCodexCli() + const hasUsableAgentRuntime = codex.ok || (podman.ok && claude.ok) + if (hasUsableAgentRuntime) { + podman.required = false + claude.required = false + codex.required = false + } const checks: Check[] = [ { ok: true, label: `workspace: ${workspaceDir()}` }, { ok: true, label: `team .claude/CLAUDE.md (${existsSync(workspaceTeamClaudeMdPath()) ? "present" : "absent"})` }, @@ -134,8 +158,9 @@ export async function printBootstrapBanner(cfg: WorkspaceConfig) { { ok: true, label: `repos: (per-user, in personal config)` }, await checkUsers(), { ok: existsSync(configPath()), label: `config: ${configPath()}` }, - checkPodman(), - checkClaudeBinary(), + podman, + claude, + codex, checkGitCrypt(), ] @@ -154,9 +179,9 @@ export async function printBootstrapBanner(cfg: WorkspaceConfig) { if (!c.ok && c.hint) console.log(` ${yellow("→ " + c.hint)}`) } console.log(bar) - const blockers = checks.filter((c) => !c.ok) + const blockers = checks.filter((c) => !c.ok && c.required !== false) if (blockers.length > 0) { - console.log(` ${yellow(`${blockers.length} thing(s) to fix`)} before chat will work — see hints above.\n`) + console.log(` ${yellow(`${blockers.length} thing(s) to fix`)} before all required startup checks pass — see hints above.\n`) return false } // NB: the "ready. open …" line is intentionally NOT printed here. The banner diff --git a/server/src/codex-cli.ts b/server/src/codex-cli.ts new file mode 100644 index 00000000..74c70828 --- /dev/null +++ b/server/src/codex-cli.ts @@ -0,0 +1,145 @@ +type JsonObject = Record + +export type CodexEvent = JsonObject & { type: string } + +export type CodexUsage = { + inputTokens: number + outputTokens: number + cachedInputTokens: number +} + +export type CodexCompletedItem = { + type: string + text?: string +} + +function asObject(value: unknown): JsonObject | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as JsonObject + : null +} + +function errorMessage(value: unknown): string | null { + let current = value + for (let depth = 0; depth < 4; depth += 1) { + if (typeof current === "string") { + const text = current.trim() + if (!text) return null + try { + current = JSON.parse(text) as unknown + continue + } catch { + return text + } + } + + const record = asObject(current) + if (!record) return null + const nestedError = asObject(record.error) + current = nestedError?.message ?? record.message ?? record.error + } + return typeof current === "string" && current.trim() ? current.trim() : null +} + +export function parseCodexEvent(line: string): CodexEvent | null { + const text = line.trim() + if (!text) return null + try { + const value = asObject(JSON.parse(text) as unknown) + return value && typeof value.type === "string" + ? value as CodexEvent + : null + } catch { + return null + } +} + +/** Codex emits terminal API failures as JSONL events on stdout. */ +export function codexEventError(event: unknown): string | null { + const record = asObject(event) + if (!record) return null + if (record.type === "turn.failed") return errorMessage(record.error) + if (record.type === "error") return errorMessage(record.message ?? record.error) + return null +} + +export function codexThreadId(event: CodexEvent): string | null { + return event.type === "thread.started" && typeof event.thread_id === "string" + ? event.thread_id + : null +} + +export function codexCompletedItem(event: CodexEvent): CodexCompletedItem | null { + if (event.type !== "item.completed") return null + const item = asObject(event.item) + if (!item || typeof item.type !== "string") return null + return { + type: item.type, + ...(typeof item.text === "string" ? { text: item.text } : {}), + } +} + +export function codexTurnUsage(event: CodexEvent): CodexUsage | null { + if (event.type !== "turn.completed") return null + const usage = asObject(event.usage) + if (!usage) return null + return { + inputTokens: typeof usage.input_tokens === "number" ? usage.input_tokens : 0, + outputTokens: typeof usage.output_tokens === "number" ? usage.output_tokens : 0, + cachedInputTokens: typeof usage.cached_input_tokens === "number" ? usage.cached_input_tokens : 0, + } +} + +export function codexBinary(): string { + return process.env.LOOPAT_CODEX_BIN || "codex" +} + +export function buildCodexEnv(opts: { + apiKey?: string + baseUrl?: string +}, baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...baseEnv } + if (opts.apiKey) { + env.CODEX_API_KEY = opts.apiKey + env.OPENAI_API_KEY = opts.apiKey + } + const baseUrl = opts.baseUrl?.trim().replace(/\/+$/, "") + if (baseUrl) { + env.CODEX_BASE_URL = baseUrl + env.OPENAI_BASE_URL = baseUrl + } + return env +} + +export function buildCodexExecArgs(opts: { + workdir: string + threadId?: string | null + modelArg?: string + sandbox?: "read-only" | "workspace-write" + ephemeral?: boolean +}): string[] { + const modelArgs = opts.modelArg ? ["-m", opts.modelArg] : [] + if (opts.threadId) { + return [ + "exec", + "resume", + "--json", + "--skip-git-repo-check", + ...modelArgs, + opts.threadId, + "-", + ] + } + return [ + "exec", + "--json", + "--sandbox", + opts.sandbox ?? "workspace-write", + ...(opts.ephemeral ? ["--ephemeral"] : []), + "--cd", + opts.workdir, + "--skip-git-repo-check", + ...modelArgs, + "-", + ] +} diff --git a/server/src/config.ts b/server/src/config.ts index 76779660..816591f1 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -70,10 +70,27 @@ export function normalizeModelEntry(m: ModelEntryDisk): ModelEntry { return { id: m.id, ...(m.maxContextTokens ? { maxContextTokens: m.maxContextTokens } : {}), ...(m.tier ? { tier: m.tier } : {}) } } +export type ProviderRuntime = "claude" | "codex" + +export function normalizeProviderRuntime(runtime: unknown): ProviderRuntime { + return runtime === "codex" ? "codex" : "claude" +} + +function normalizeProviderModelsFromDisk(p: { models?: ModelEntryDisk[]; model?: string }): ModelEntry[] { + if (Array.isArray(p.models) && p.models.length > 0) { + return p.models.map(m => normalizeModelEntry(m)) + } + if (typeof p.model === "string" && p.model.trim()) { + return [{ id: p.model.trim() }] + } + return [] +} + export type ProviderPreset = { name: string baseUrl: string models: ModelEntryDisk[] + runtime?: ProviderRuntime } export type MiseToolPreset = { @@ -95,9 +112,13 @@ export type PresetsData = { */ export type ProviderConfigDisk = { models?: ModelEntryDisk[] + /** Legacy single-model field accepted from older config.json files and clients. */ + model?: string baseUrl: string apiKey?: string enabled?: boolean + /** Which agent runtime should consume this provider. Defaults to Claude Code. */ + runtime?: ProviderRuntime /** Per-tier model. Written → passed as ANTHROPIC_DEFAULT_*_MODEL; absent → CC native. */ opus_model?: string sonnet_model?: string @@ -112,6 +133,7 @@ export type ProviderConfig = { baseUrl: string apiKey: string enabled: boolean + runtime: ProviderRuntime opus_model?: string sonnet_model?: string haiku_model?: string @@ -333,28 +355,31 @@ export function pickProvider( if (seen.has(name)) continue seen.add(name) const p = pCfg.providers[name] ?? wCfg.providers?.[name] - if (p && (!requireKey || p.apiKey)) return { name, provider: p } + if (!p || p.enabled === false) continue + if (p && (!requireKey || p.apiKey || p.runtime === "codex")) return { name, provider: p } } return null } -/** Preset providers with Anthropic-compatible endpoints. loopat uses the - * Claude Agent SDK which speaks the Anthropic Messages API — only providers - * that expose an Anthropic-compatible endpoint work directly. - * Each provider is disabled by default; the user supplies an API key. */ +/** Preset providers. Claude runtime entries use Anthropic-compatible endpoints; + * Codex runtime entries use the local Codex CLI and can rely on `codex login`. */ import { DEFAULT_PROVIDER_PRESETS } from "./presets" function buildPresetProviders(): Record { return Object.fromEntries( - DEFAULT_PROVIDER_PRESETS.map(p => [ - p.name, - { - models: p.models.map(m => normalizeModelEntry(m)), - baseUrl: p.baseUrl, - apiKey: "", - enabled: false, - } satisfies ProviderConfig, - ]), + DEFAULT_PROVIDER_PRESETS.map(p => { + const runtime = normalizeProviderRuntime(p.runtime) + return [ + p.name, + { + models: p.models.map(m => normalizeModelEntry(m)), + baseUrl: p.baseUrl, + apiKey: "", + enabled: runtime === "codex", + runtime, + } satisfies ProviderConfig, + ] + }), ) } @@ -378,10 +403,12 @@ const PERSONAL_DISK_TEMPLATE: PersonalConfigDisk = { default: DEFAULT_PROVIDER_PRESETS[0] ? `${DEFAULT_PROVIDER_PRESETS[0].name}/${normalizeModelEntry(DEFAULT_PROVIDER_PRESETS[0].models[0]).id}` : "", } for (const p of DEFAULT_PROVIDER_PRESETS) { + const runtime = normalizeProviderRuntime(p.runtime) providers[p.name] = { models: p.models.map(m => normalizeModelEntry(m)), baseUrl: p.baseUrl, - enabled: false, + runtime, + enabled: runtime === "codex", } } return providers @@ -409,9 +436,12 @@ export async function loadConfig(): Promise { if (cachedWorkspace && mtimeMs === cachedWorkspaceMtimeMs) return cachedWorkspace const raw = await readFile(path, "utf8") const parsed = JSON.parse(raw) as WorkspaceConfig + parsed.providers = { ...buildPresetProviders(), ...(parsed.providers ?? {}) } if (parsed.providers) { for (const [, p] of Object.entries(parsed.providers)) { if (p.enabled === undefined) (p as any).enabled = true + ;(p as any).runtime = normalizeProviderRuntime((p as any).runtime) + ;(p as any).models = normalizeProviderModelsFromDisk(p as any) } } cachedWorkspace = parsed @@ -512,15 +542,14 @@ export async function loadPersonalConfig( const vaultPath = join(personalVaultDir(user, vault), (p.apiKey as any).vault as string) try { apiKey = (await readFile(vaultPath, "utf8")).trim() } catch {} } - // Normalize: mixed string|object → canonical ModelEntry[]. - const models: ModelEntry[] = Array.isArray(p.models) - ? p.models.map(m => normalizeModelEntry(m as ModelEntryDisk)) - : [] + // Normalize both canonical `models` and the legacy single `model`. + const models = normalizeProviderModelsFromDisk(p) providers[name] = { models, baseUrl: p.baseUrl, apiKey, enabled: p.enabled !== false, + runtime: normalizeProviderRuntime(p.runtime), ...(typeof p.opus_model === "string" && p.opus_model ? { opus_model: p.opus_model } : {}), ...(typeof p.sonnet_model === "string" && p.sonnet_model ? { sonnet_model: p.sonnet_model } : {}), ...(typeof p.haiku_model === "string" && p.haiku_model ? { haiku_model: p.haiku_model } : {}), @@ -680,12 +709,17 @@ export async function savePersonalDisk( return { ok: false, error: `provider "${name}" must be an object` } } const p = val as ProviderConfigDisk - if (!Array.isArray(p.models) || p.models.length === 0) { + const models = normalizeProviderModelsFromDisk(p) + if (models.length === 0) { return { ok: false, error: `provider "${name}" missing models` } } + p.models = models if (typeof p.baseUrl !== "string") { return { ok: false, error: `provider "${name}" missing baseUrl` } } + if (p.runtime !== undefined && p.runtime !== "claude" && p.runtime !== "codex") { + return { ok: false, error: `provider "${name}" runtime must be "claude" or "codex"` } + } if (p.apiKey !== undefined && typeof p.apiKey !== "string" && !(typeof p.apiKey === "object" && typeof (p.apiKey as any).vault === "string")) { return { ok: false, error: `provider "${name}" apiKey must be a string or { vault }` } } @@ -705,7 +739,7 @@ export async function savePersonalDisk( for (const [name, val] of Object.entries(patch.providers)) { if (name === "default" || !val || typeof val !== "object") continue const p = val as ProviderConfigDisk - if (p.enabled !== false) { + if (p.enabled !== false && normalizeProviderRuntime(p.runtime) !== "codex") { const hasNewKey = (typeof p.apiKey === "string" && p.apiKey.length > 0) || (p.apiKey && typeof (p.apiKey as any).vault === "string") const existingEntry = disk.providers[name] const existingKey = (existingEntry && typeof existingEntry === "object") ? (existingEntry as ProviderConfigDisk).apiKey : undefined @@ -785,7 +819,7 @@ async function readPersonalDisk(user: string): Promise { */ export async function savePersonalConfig(user: string, cfg: { default?: string - providers?: Record + providers?: Record }): Promise { const disk = await readPersonalDisk(user) const existingDefault = typeof disk.providers.default === "string" ? disk.providers.default : "" @@ -818,14 +852,13 @@ export async function savePersonalConfig(user: string, cfg: { // No new key, no existing key → leave field unset (provider disabled). apiKeyField = undefined } - const models: ModelEntry[] = Array.isArray(p.models) - ? p.models.map(m => normalizeModelEntry(m as ModelEntryDisk)) - : [] + const models = normalizeProviderModelsFromDisk(p) rebuilt[name] = { baseUrl: p.baseUrl, ...(apiKeyField !== undefined ? { apiKey: apiKeyField } : {}), ...(models.length > 0 ? { models } : {}), ...(p.enabled === false ? { enabled: false } : {}), + ...(p.runtime && p.runtime !== "claude" ? { runtime: p.runtime } : {}), ...(p.opus_model ? { opus_model: p.opus_model } : {}), ...(p.sonnet_model ? { sonnet_model: p.sonnet_model } : {}), ...(p.haiku_model ? { haiku_model: p.haiku_model } : {}), @@ -871,14 +904,18 @@ export async function saveWorkspaceConfig(cfg: Partial): Promis for (const [name, p] of Object.entries(cfg.providers)) { const existingProv = merged.providers[name] const incoming = p as any - const models: ModelEntry[] = incoming.models?.length > 0 - ? incoming.models.map((m: any) => normalizeModelEntry(m as ModelEntryDisk)) - : existingProv?.models ?? [] + const incomingModels = normalizeProviderModelsFromDisk(incoming) + const models: ModelEntry[] = incomingModels.length > 0 ? incomingModels : existingProv?.models ?? [] merged.providers[name] = { models, baseUrl: incoming.baseUrl ?? existingProv?.baseUrl ?? "", apiKey: incoming.apiKey || existingProv?.apiKey || "", enabled: incoming.enabled !== undefined ? incoming.enabled : (existingProv?.enabled ?? true), + runtime: normalizeProviderRuntime(incoming.runtime ?? existingProv?.runtime), + ...(incoming.opus_model ?? existingProv?.opus_model ? { opus_model: incoming.opus_model ?? existingProv?.opus_model } : {}), + ...(incoming.sonnet_model ?? existingProv?.sonnet_model ? { sonnet_model: incoming.sonnet_model ?? existingProv?.sonnet_model } : {}), + ...(incoming.haiku_model ?? existingProv?.haiku_model ? { haiku_model: incoming.haiku_model ?? existingProv?.haiku_model } : {}), + ...(incoming.agent_model ?? existingProv?.agent_model ? { agent_model: incoming.agent_model ?? existingProv?.agent_model } : {}), } as any } } diff --git a/server/src/github.ts b/server/src/github.ts index e0c494c9..1c23e662 100644 --- a/server/src/github.ts +++ b/server/src/github.ts @@ -171,6 +171,25 @@ export async function ensureCollaborator( if (r.status !== 201 && r.status !== 204) fail("add collaborator", r) } +type OnboardingProviderConfig = { + apiKey?: unknown + runtime?: unknown + enabled?: unknown + baseUrl?: string + model?: string +} + +function providerConfigs(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {} +} + +function providerReady(value: OnboardingProviderConfig): boolean { + return (typeof value.apiKey === "string" && value.apiKey.trim().length > 0) || + (value.runtime === "codex" && value.enabled !== false) +} + /** The built-in GitHub provider — adapts the functions above onto GitHostProvider. */ export const githubProvider: GitHostProvider = { id: "github", @@ -183,7 +202,11 @@ export const githubProvider: GitHostProvider = { // the device dance (start/poll) and, on token, provisions the repo. Once the // repo is imported there's nothing left to gate on, so we're done. async onboarding(ctx) { + const wsProviders = providerConfigs(ctx.workspaceConfig?.providers) + const hasWorkspaceKey = Object.values(wsProviders).some(providerReady) if (!ctx.personalRepoImported) { + // Workspace-level AI, including keyless Codex, is enough for local-only loops. + if (hasWorkspaceKey) return { done: true } return { done: false, show: { @@ -196,10 +219,8 @@ export const githubProvider: GitHostProvider = { // Step 2: need at least one usable AI key. Check personal config first // (keys already expanded from vault), then workspace-shared providers so // users can skip the key-entry form when the workspace already has keys. - const providers = (ctx.config?.providers ?? {}) as Record - const hasPersonalKey = Object.values(providers).some((p) => p && typeof p.apiKey === "string" && p.apiKey.trim().length > 0) - const wsProviders = (ctx.workspaceConfig?.providers ?? {}) as Record - const hasWorkspaceKey = Object.values(wsProviders).some((p) => p && typeof p.apiKey === "string" && p.apiKey.trim().length > 0) + const providers = providerConfigs(ctx.config?.providers) + const hasPersonalKey = Object.values(providers).some(providerReady) const hasKey = hasPersonalKey || hasWorkspaceKey const anthropic = providers.anthropic ?? {} if (!hasKey) { diff --git a/server/src/index.ts b/server/src/index.ts index 435b4a8d..e166b781 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -40,6 +40,7 @@ import { LOOPAT_HOME, LOOPAT_INSTALL_DIR, WORKSPACE, + workspaceDir, loopContextKnowledge, loopContextNotes, loopContextPersonal, @@ -57,13 +58,20 @@ import { personalReposDir, loopsDir, } from "./paths" -import { loadConfig, loadPersonalConfig, savePersonalConfig, saveWorkspaceConfig, getActiveProvider, readPersonalDiskRaw, savePersonalDisk, describeApiKeyRef, writeVaultEnv, deleteVaultEnv, loadA2AConfig, saveA2AConfig, type ProviderConfig, type ModelEntry } from "./config" +import { loadConfig, loadPersonalConfig, savePersonalConfig, saveWorkspaceConfig, getActiveProvider, readPersonalDiskRaw, savePersonalDisk, describeApiKeyRef, writeVaultEnv, deleteVaultEnv, loadA2AConfig, saveA2AConfig, type ProviderConfig, type ProviderRuntime, type ModelEntry } from "./config" import { queryUserTokenUsage, queryWorkspaceTokenUsage, queryDailyTokenUsage, queryLoopTokenUsage } from "./usage" import { createApiToken, listApiTokens, revokeApiToken } from "./api-tokens" import { listBoards, createBoard, renameBoard, listKanbanColumns, addCard, toggleCard, deleteCard, moveCard, updateCardMeta, updateCardBlock, reorderCards, createColumn, deleteColumn, readKanbanConfig, saveColumnOrder, setColumnColor, renameColumn, assignDriverForCard, createLoopFromCard, linkLoopToCard, kanbanUserCtx } from "./kanban" import { printBootstrapBanner, printReadyLine } from "./bootstrap" import { resolveProvider } from "./providers" import { ensureSandboxClaudeBinary } from "./claude-binary" +import { + buildCodexEnv, + buildCodexExecArgs, + codexBinary, + codexEventError, + parseCodexEvent, +} from "./codex-cli" import { serveHostExec, hostExecSocketPath } from "./host-exec" import { createUser, @@ -267,11 +275,11 @@ app.get("/api/serve/check-port", requireAuth, async (c) => { // (they carry per-user apiKeys via secrets/). Source field indicates origin. app.get("/api/providers", requireAuth, async (c) => { const wCfg = await loadConfig() - const providers: Record = {} + const providers: Record = {} if (wCfg.providers) { for (const [name, p] of Object.entries(wCfg.providers)) { const hasKey = typeof p.apiKey === "string" && p.apiKey.length > 0 - providers[name] = { models: p.models, baseUrl: p.baseUrl, source: "workspace", enabled: hasKey ? p.enabled : false, hasKey } + providers[name] = { models: p.models, baseUrl: p.baseUrl, runtime: p.runtime, source: "workspace", enabled: (hasKey || p.runtime === "codex") ? p.enabled : false, hasKey } } } // Overlay personal providers (they take precedence) @@ -283,8 +291,8 @@ app.get("/api/providers", requireAuth, async (c) => { const hasKey = typeof p.apiKey === "string" && p.apiKey.length > 0 // Only overlay if the user actually configured this provider (has a key). // Template/preset providers without a key should not shadow workspace config. - if (hasKey) { - providers[name] = { models: p.models, baseUrl: p.baseUrl, source: "personal", enabled: p.enabled !== false, hasKey } + if (hasKey || p.runtime === "codex") { + providers[name] = { models: p.models, baseUrl: p.baseUrl, runtime: p.runtime, source: "personal", enabled: p.enabled !== false, hasKey } } } active = pCfg.default || active @@ -292,6 +300,61 @@ app.get("/api/providers", requireAuth, async (c) => { return c.json({ providers, default: active }) }) +async function testCodexConnection(baseUrl: string, apiKey: string, model: string): Promise<{ ok: boolean; error?: string }> { + const args = buildCodexExecArgs({ + workdir: workspaceDir(), + modelArg: apiKey && model ? model : undefined, + sandbox: "read-only", + ephemeral: true, + }) + + return await new Promise((resolve) => { + const child = spawn(codexBinary(), args, { + env: buildCodexEnv({ apiKey, baseUrl }), + stdio: ["pipe", "pipe", "pipe"], + }) + let stdout = "" + let stderr = "" + let settled = false + let timer: ReturnType | null = null + const finish = (result: { ok: boolean; error?: string }) => { + if (settled) return + settled = true + if (timer) clearTimeout(timer) + resolve(result) + } + child.stdin.end("Respond with exactly: ok") + timer = setTimeout(() => { + try { child.kill("SIGTERM") } catch {} + finish({ ok: false, error: "codex test timed out" }) + }, 60_000) + child.stdout.on("data", (b) => { stdout += b.toString("utf8") }) + child.stderr.on("data", (b) => { stderr += b.toString("utf8") }) + child.on("error", (error: NodeJS.ErrnoException) => { + finish({ + ok: false, + error: error.code === "ENOENT" ? "codex CLI not found on PATH" : error.message, + }) + }) + child.on("exit", (code) => { + let eventError = "" + for (const line of stdout.split("\n")) { + const event = parseCodexEvent(line) + if (!event) continue + eventError = codexEventError(event) ?? eventError + } + if (code !== 0 || eventError) { + finish({ + ok: false, + error: (eventError || stderr.trim() || `codex exited with code ${code}`).slice(0, 400), + }) + return + } + finish({ ok: true }) + }) + }) +} + // Test a provider + model connection by making a minimal Messages API call. // Accepts either a plain apiKey, or a provider name + source to resolve the // key server-side (so tests work for stored/encrypted keys without re-typing). @@ -302,21 +365,31 @@ app.post("/api/providers/test", requireAuth, async (c) => { if (typeof model !== "string" || !model) return c.json({ ok: false, error: "model required" }, 400) let apiKey = typeof rawApiKey === "string" ? rawApiKey.trim() : "" + let runtime: ProviderRuntime = body.runtime === "codex" ? "codex" : "claude" // Resolve key server-side when a stored (encrypted) key is being tested if (!apiKey && typeof provider === "string" && provider) { if (source === "personal") { const userId = c.get("userId") as string try { const pCfg = await loadPersonalConfig(userId) - apiKey = pCfg.providers[provider]?.apiKey ?? "" + const p = pCfg.providers[provider] + apiKey = p?.apiKey ?? "" + runtime = p?.runtime ?? runtime } catch {} } else if (source === "workspace") { try { const wCfg = await loadConfig() - apiKey = (wCfg.providers?.[provider] as any)?.apiKey ?? "" + const p = wCfg.providers?.[provider] + apiKey = (p as any)?.apiKey ?? "" + runtime = p?.runtime ?? runtime } catch {} } } + + if (runtime === "codex") { + return c.json(await testCodexConnection(baseUrl, apiKey, model)) + } + if (!apiKey) return c.json({ ok: false, error: "no API key — enter one or store it first" }, 400) try { @@ -626,11 +699,12 @@ app.get("/api/settings/personal", requireAuth, async (c) => { const userId = c.get("userId") as string const cfg = await loadPersonalConfig(userId) const tokenUsage = queryUserTokenUsage(userId) - const providers: Record = {} + const providers: Record = {} for (const [name, p] of Object.entries(cfg.providers)) { providers[name] = { models: p.models, baseUrl: p.baseUrl, + runtime: p.runtime, hasKey: !!p.apiKey, enabled: p.enabled, } @@ -1007,10 +1081,10 @@ app.post("/api/plugins/refresh", requireAuth, async (c) => { app.get("/api/settings/workspace", requireAuth, requireAdmin, async (c) => { const cfg = await loadConfig() - const providers: Record = {} + const providers: Record = {} if (cfg.providers) { for (const [name, p] of Object.entries(cfg.providers)) { - providers[name] = { models: p.models, baseUrl: p.baseUrl, hasKey: !!(p as any).apiKey, enabled: p.enabled } + providers[name] = { models: p.models, baseUrl: p.baseUrl, runtime: p.runtime, hasKey: !!(p as any).apiKey, enabled: p.enabled } } } const tokenUsage = queryWorkspaceTokenUsage() diff --git a/server/src/podman.ts b/server/src/podman.ts index 8c357bef..c7343936 100644 --- a/server/src/podman.ts +++ b/server/src/podman.ts @@ -589,12 +589,17 @@ export async function probePodman(): Promise { try { const { stdout } = await runPodman(["--version"]) const version = stdout.trim() + if (process.platform === "darwin") { + await runPodman(["info"]) + } return { ok: true, version } } catch (e: any) { return { ok: false, hint: e?.message?.includes("not found") - ? "install with: sudo apt install podman uidmap fuse-overlayfs" + ? (process.platform === "darwin" ? "install with: brew install podman" : "install with: sudo apt install podman uidmap fuse-overlayfs") + : process.platform === "darwin" + ? `podman machine unavailable: ${e?.message ?? e}; run: podman machine init && podman machine start` : `podman probe failed: ${e?.message ?? e}`, } } diff --git a/server/src/presets.ts b/server/src/presets.ts index daeb311f..6329cfc4 100644 --- a/server/src/presets.ts +++ b/server/src/presets.ts @@ -1,4 +1,8 @@ -export const DEFAULT_PROVIDER_PRESETS: Array<{ name: string; baseUrl: string; models: Array }> = [ +export const DEFAULT_PROVIDER_PRESETS: Array<{ name: string; baseUrl: string; runtime?: "claude" | "codex"; models: Array }> = [ + { name: "Codex", baseUrl: "https://api.openai.com/v1", runtime: "codex", + models: [ + { id: "gpt-5-codex", maxContextTokens: 20_000_000 }, + ]}, { name: "Anthropic", baseUrl: "https://api.anthropic.com", models: [ "claude-sonnet-4-20250514", @@ -39,4 +43,4 @@ export const DEFAULT_MISE_TOOL_PRESETS: Array<{ name: string; suggestedVersion: { name: "ripgrep", suggestedVersion: "14.1", description: "Line-oriented search tool", backend: "aqua:BurntSushi/ripgrep" }, { name: "fd", suggestedVersion: "10.2", description: "Fast file finder", backend: "aqua:sharkdp/fd" }, { name: "jq", suggestedVersion: "1.7", description: "Command-line JSON processor", backend: "aqua:jqlang/jq" }, -] \ No newline at end of file +] diff --git a/server/src/session.ts b/server/src/session.ts index bd3432f7..d4d6c5f8 100644 --- a/server/src/session.ts +++ b/server/src/session.ts @@ -4,7 +4,7 @@ import { appendFile, readFile, readdir, rm, writeFile, mkdir } from "node:fs/pro import { createWriteStream, mkdirSync, existsSync } from "node:fs" import { randomUUID } from "node:crypto" import { join } from "node:path" -import { loopClaudeDir, loopDir, loopHistoryPath, personalSkillsDir, workspaceTeamSkillsDir } from "./paths" +import { loopClaudeDir, loopDir, loopHistoryPath, loopWorkdir, personalSkillsDir, workspaceTeamSkillsDir } from "./paths" import { appendLoopUsage, appendLoopUsageClear, insertUsageDb, type UsageEntry } from "./usage" import { resolveSandboxClaudeBinary } from "./claude-binary" import { loadConfig, loadPersonalConfig, parseDefault, getModelByTier, pickProvider, type ProviderConfig } from "./config" @@ -19,6 +19,17 @@ import { updateLoopStatus, setLoopPhase } from "./loop-status" import { tracer, withSpan } from "./tracer" import { SpanStatusCode, type Span } from "@opentelemetry/api" import { maybeAutoName } from "./auto-name" +import { + buildCodexEnv, + buildCodexExecArgs, + codexBinary, + codexCompletedItem, + codexEventError, + codexThreadId, + codexTurnUsage, + parseCodexEvent, + type CodexUsage, +} from "./codex-cli" // Tests override LOOPAT_CLAUDE_BIN to point at a mock binary (a script that // reads stream-json from stdin and writes canned messages back) so we can @@ -202,6 +213,16 @@ type QueuedMessage = { export type LoopSessionMessageListener = (msg: any) => void +type CodexRuntimeContext = { + loopId: string + driver: string + providerName: string + provider: ProviderConfig + modelId: string + modelArg?: string + loopatAppend: string +} + class LoopSession { id: string private q: Query | null = null @@ -229,6 +250,10 @@ class LoopSession { private usageSession = 0 private currentDriver: string | null = null private gateway: LoopGateway | null = null + private codexRuntime: CodexRuntimeContext | null = null + private codexProc: ReturnType | null = null + private interruptedCodexProcesses = new WeakSet>() + private destroyed = false constructor(id: string) { this.id = id @@ -243,9 +268,10 @@ class LoopSession { } private scheduleIdleCleanup() { + if (this.destroyed) return if (this.idleTimer) return if (this.subscribers.size > 0) return - if (this.consuming) return // never interrupt an active generation + if (this.consuming || this.generating) return // never interrupt an active generation const tag = this.id.slice(0, 8) this.idleTimer = setTimeout(() => { this.idleTimer = null @@ -256,6 +282,22 @@ class LoopSession { }, IDLE_TIMEOUT_MS) } + private autoNameLoop(): void { + maybeAutoName(this.id).then(async (didName) => { + if (!didName) return + const fresh = await getLoop(this.id) + if (fresh) this.broadcast({ type: "meta_updated", meta: fresh }) + }).catch(() => {}) + } + + private stopCodexProcess(): void { + const proc = this.codexProc + this.codexProc = null + if (!proc) return + this.interruptedCodexProcesses.add(proc) + try { proc.kill("SIGTERM") } catch {} + } + private async resolveProvider(meta: { createdBy: string; driver?: string; config?: { vault?: string } }, candidateNames: (string | null | undefined)[], requireKey: boolean): Promise<{ name: string; provider: ProviderConfig } | null> { const pCfg = await loadPersonalConfig(effectiveDriver(meta), meta.config?.vault) const wCfg = await loadConfig() @@ -263,11 +305,8 @@ class LoopSession { } /** - * Set the active provider. Takes effect on the next user message — each - * turn spawns a fresh claude binary via `ensureStarted`, which calls - * `buildLoopEnv` with the current `providerOverride`. No need to - * interrupt the running turn; the new provider applies naturally when - * the current response finishes and the next message triggers a new spawn. + * Set the active provider. The current runtime is stopped so the next user + * message resolves the new provider and starts the matching runtime. */ setProvider(name: string | null) { this.providerOverride = name @@ -290,8 +329,8 @@ class LoopSession { setAt: this.goalSetAt, status: this.goalStatus, }) - if (this.q) { - // Re-compose: the next ensureStarted picks up the goal via buildLoopatAppend. + if (this.q || this.codexRuntime) { + // The next ensureStarted rebuilds the system prompt with the new goal. this.restartOnNextMessage() } } @@ -314,14 +353,9 @@ class LoopSession { } /** - * Interrupt the current `query()` and clear `this.q`, so the next user - * message triggers a fresh `ensureStarted()` — picking up changes to env - * vars, provider config, **mcpServers**, etc. Conversation history is - * preserved because the SDK reads its session JSONL from disk on respawn - * (`continue: true` when `hasPriorSdkSession` is true). - * - * Idempotent: calling on a session that doesn't currently hold a query is - * a no-op. Fire-and-forget; the interrupt runs in the background. + * Stop the active runtime so the next user message re-runs `ensureStarted()`. + * Claude resumes from its SDK JSONL; Codex resumes from its persisted thread. + * Fire-and-forget and idempotent. */ restartOnNextMessage() { if (this.q) { @@ -330,6 +364,8 @@ class LoopSession { this.input = pushIterable() dying.interrupt().catch(() => {}) } + this.stopCodexProcess() + this.codexRuntime = null } private async loadHistoryFromDisk() { @@ -347,10 +383,9 @@ class LoopSession { } private async ensureStarted() { - if (this.q) return + if (this.q || this.codexRuntime) return return withSpan("ensureStarted", async (rootSpan) => { rootSpan.setAttribute("loop.id", this.id.slice(0, 8)) - const shouldContinue = await hasPriorSdkSession(this.id) const meta = await getLoop(this.id) if (!meta) { throw new Error(`loop ${this.id} meta missing`) @@ -376,6 +411,44 @@ class LoopSession { const loopatAppend = await buildLoopatAppend(meta) const loopId = this.id + let modelId: string | undefined = meta.config?.default_model_id + if (!modelId) { + const pCfg = await loadPersonalConfig(driver, meta.config?.vault) + const defaultParsed = parseDefault(pCfg.default) + if (defaultParsed.modelId && defaultParsed.providerName === providerName) { + modelId = defaultParsed.modelId + } + } + const activeModel = (modelId ? provider.models.find(m => m.id === modelId) : undefined) + ?? provider.models[0] + const autoCompactWindow = activeModel?.maxContextTokens + + // Codex is a host CLI runtime. It does not need Claude settings, plugins, + // podman, or the Anthropic egress gateway below. + if (provider.runtime === "codex") { + this.codexRuntime = { + loopId, + driver, + providerName, + provider, + modelId: activeModel?.id ?? modelId ?? "", + modelArg: provider.apiKey ? (activeModel?.id ?? modelId ?? "") : undefined, + loopatAppend, + } + this.broadcast({ + type: "provider", + name: providerName, + model: this.codexRuntime.modelId, + models: provider.models, + contextWindow: resolveContextWindow(provider, this.codexRuntime.modelId), + runtime: "codex", + }) + return + } + + this.codexRuntime = null + const shouldContinue = await hasPriorSdkSession(this.id) + // Compose runs ONCE at loop creation (loops.ts:createLoop). At spawn we // only re-compose if the snapshot is missing — this happens for loops // created before the snapshot model landed, and self-heals on first spawn. @@ -430,17 +503,6 @@ class LoopSession { if (!this.gateway) this.gateway = startLoopGateway(loopId, extraEnv.ANTHROPIC_BASE_URL ?? provider.baseUrl, !!process.env.LOOPAT_EGRESS_TRACE) extraEnv.ANTHROPIC_BASE_URL = `http://host.containers.internal:${this.gateway.port}` - let modelId: string | undefined = meta.config?.default_model_id - if (!modelId) { - const pCfg = await loadPersonalConfig(driver, meta.config?.vault) - const defaultParsed = parseDefault(pCfg.default) - if (defaultParsed.modelId && defaultParsed.providerName === providerName) { - modelId = defaultParsed.modelId - } - } - const activeModel = (modelId ? provider.models.find(m => m.id === modelId) : undefined) - ?? provider.models[0] - const autoCompactWindow = activeModel?.maxContextTokens // Ensure the per-loop podman container exists and is running. Idempotent: // if the container is already up with the same config-hash, no-op. let building = false @@ -759,14 +821,7 @@ class LoopSession { resultReceived = true this.turnSpan?.end() this.turnSpan = null - // Fire-and-forget: try to auto-name the loop if title is still - // "untitled". maybeAutoName() is fully idempotent + best-effort - // (no-ops if title is already set or user has opted out). - maybeAutoName(this.id).then(async (didName) => { - if (!didName) return - const fresh = await getLoop(this.id) - if (fresh) this.broadcast({ type: "meta_updated", meta: fresh }) - }).catch(() => {}) + this.autoNameLoop() } else if ( // Inject queued messages at tool-result boundaries — matching // real Claude Code's per-step queue consumption. @@ -1094,6 +1149,7 @@ class LoopSession { model: activeModelId, models: resolved.provider.models, contextWindow: resolveContextWindow(resolved.provider, activeModelId), + runtime: resolved.provider.runtime, })) } else { console.warn(`[loop:${this.id.slice(0, 8)}] no provider found in personal or workspace config`) @@ -1308,13 +1364,232 @@ class LoopSession { this.history.push(userMsg) this.persist(userMsg) this.broadcast(userMsg) + if (this.codexRuntime) { + await this.runCodexTurn(text, this.codexRuntime, images) + return + } this.input.push(userMsg) } + private codexThreadPath(): string { + return join(loopDir(this.id), "codex-thread.json") + } + + private async loadCodexThreadId(): Promise { + try { + const j = JSON.parse(await readFile(this.codexThreadPath(), "utf8")) + return typeof j?.threadId === "string" && j.threadId ? j.threadId : null + } catch { + return null + } + } + + private async saveCodexThreadId(threadId: string): Promise { + await mkdir(loopDir(this.id), { recursive: true }) + await writeFile(this.codexThreadPath(), JSON.stringify({ threadId }, null, 2) + "\n") + } + + private buildCodexPrompt(text: string, ctx: CodexRuntimeContext, images?: ImageInput[]): string { + const imageNote = images && images.length > 0 + ? `\n\n[loopat] ${images.length} image attachment(s) were omitted because this Codex runtime adapter only forwards text today. Ask the user to describe or reattach them as files if they matter.` + : "" + return [ + ctx.loopatAppend.trim(), + `\n\n[loopat] You are running inside loopat via OpenAI Codex CLI. The workspace root is:\n${loopWorkdir(ctx.loopId)}\n`, + "Reply normally to the user. If you edit files or run commands, summarize the important results.", + "\n\nUser message:\n", + text, + imageNote, + ].join("") + } + + private async runCodexTurn(text: string, ctx: CodexRuntimeContext, images?: ImageInput[]): Promise { + const loopId = ctx.loopId + const tag = loopId.slice(0, 8) + const startedAt = Date.now() + this.generating = true + this.queueProcessing = false + updateLoopStatus(loopId, "Codex running...") + + const workdir = loopWorkdir(loopId) + const prompt = this.buildCodexPrompt(text, ctx, images) + const threadId = await this.loadCodexThreadId() + const codexBin = codexBinary() + const args = buildCodexExecArgs({ workdir, threadId, modelArg: ctx.modelArg }) + + mkdirSync(loopDir(loopId), { recursive: true }) + const stderrLogPath = join(loopDir(loopId), "stderr.log") + const stderrFile = createWriteStream(stderrLogPath, { flags: "a" }) + stderrFile.write(`\n=== ${new Date().toISOString()} codex spawn ===\n`) + stderrFile.write(`binary: ${codexBin}\nargv: ${args.map((a) => (a.includes(" ") ? JSON.stringify(a) : a)).join(" ")}\n`) + + const proc = nodeSpawn(codexBin, args, { + cwd: workdir, + env: buildCodexEnv({ + apiKey: ctx.provider.apiKey, + baseUrl: ctx.provider.baseUrl, + }), + stdio: ["pipe", "pipe", "pipe"], + }) + this.codexProc = proc + + let stdoutBuf = "" + let stderrBuf = "" + let eventError = "" + let sawTurnCompleted = false + const turnUsages: CodexUsage[] = [] + + const handleLine = (line: string) => { + const trimmed = line.trim() + if (!trimmed) return + const event = parseCodexEvent(trimmed) + if (!event) { + if (DEBUG) console.error(`[codex:${tag}:stdout] ${trimmed}`) + return + } + + const parsedError = codexEventError(event) + if (parsedError) { + eventError = parsedError + stderrFile.write(`[codex event] ${parsedError}\n`) + return + } + + const startedThreadId = codexThreadId(event) + if (startedThreadId) { + this.saveCodexThreadId(startedThreadId).catch(() => {}) + return + } + + if (event.type === "turn.started") { + this.broadcast({ type: "system", subtype: "init", runtime: "codex", uuid: randomUUID() }) + return + } + + const item = codexCompletedItem(event) + if (item) { + if (item.type === "agent_message" && item.text?.trim()) { + if (this.ttfbSpan) { + this.ttfbSpan.end() + this.ttfbSpan = null + } + const assistantMsg = { + type: "assistant" as const, + message: { role: "assistant" as const, content: [{ type: "text", text: item.text }] }, + parent_tool_use_id: null, + uuid: randomUUID(), + } + this.history.push(assistantMsg as any) + this.persist(assistantMsg) + this.broadcast(assistantMsg) + this.updateStatus(assistantMsg) + } else if (DEBUG) { + console.error(`[codex:${tag}] item.completed ${item.type}`) + } + return + } + + if (event.type === "turn.completed") { + sawTurnCompleted = true + const usage = codexTurnUsage(event) + if (usage) turnUsages.push(usage) + return + } + } + + proc.stdout?.on("data", (chunk: Buffer) => { + stdoutBuf += chunk.toString("utf8") + const lines = stdoutBuf.split("\n") + stdoutBuf = lines.pop() ?? "" + for (const line of lines) handleLine(line) + }) + + proc.stderr?.on("data", (chunk: Buffer) => { + stderrFile.write(chunk) + stderrBuf += chunk.toString("utf8") + const text = chunk.toString("utf8") + for (const line of text.split("\n")) { + if (line.trim()) console.error(`[codex:${tag}:stderr] ${line}`) + } + }) + + proc.stdin?.end(prompt) + + const code = await new Promise((resolve, reject) => { + proc.on("error", reject) + proc.on("exit", (c) => resolve(c)) + }).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error) + stderrFile.write(`spawn error: ${message}\n`) + return -1 + }) + + if (stdoutBuf.trim()) handleLine(stdoutBuf) + stderrFile.end(`=== exit code=${code} ===\n`) + const interrupted = this.interruptedCodexProcesses.delete(proc) + if (this.codexProc === proc) this.codexProc = null + + if (!interrupted && (code !== 0 || eventError)) { + const detail = eventError || stderrBuf.trim() || "see stderr.log" + const err = { + type: "error", + message: code === 0 + ? `Codex turn failed: ${detail.slice(0, 1000)}` + : `Codex exited with code ${code}: ${detail.slice(0, 1000)}`, + } + this.turnSpan?.recordException(new Error(err.message)) + this.turnSpan?.setStatus({ code: SpanStatusCode.ERROR, message: err.message }) + this.history.push(err as any) + this.persist(err) + this.broadcast(err) + } + + const lastUsage = turnUsages.at(-1) + const usage = lastUsage + ? { + input_tokens: lastUsage.inputTokens, + output_tokens: lastUsage.outputTokens, + cache_read_input_tokens: lastUsage.cachedInputTokens, + } + : undefined + const result = { + type: "result" as const, + ...(usage ? { usage } : {}), + ...(lastUsage ? { + modelUsage: { + [ctx.modelId || ctx.providerName]: { + inputTokens: lastUsage.inputTokens, + outputTokens: lastUsage.outputTokens, + cacheReadInputTokens: lastUsage.cachedInputTokens, + cacheCreationInputTokens: 0, + }, + }, + } : {}), + duration_ms: Date.now() - startedAt, + runtime: "codex", + } + this.history.push(result as any) + this.persist(result) + if ((result as any).modelUsage) this.persistUsage(result as any) + this.broadcast(result) + this.turnSpan?.end() + this.turnSpan = null + this.ttfbSpan?.end() + this.ttfbSpan = null + if (sawTurnCompleted) this.autoNameLoop() + + this.generating = false + this.queueProcessing = false + updateLoopStatus(loopId, sawTurnCompleted ? "Ready" : interrupted ? "Interrupted" : "Codex stopped") + this.processNextInQueue() + this.scheduleIdleCleanup() + } + /** Process the next queued message. Called from consume()'s finally block * after each generation completes. Only starts the next message; subsequent * messages are handled recursively by consume()'s finally. */ private processNextInQueue() { + if (this.destroyed) return if (this.queueProcessing) return // already processing if (this.messageQueue.length === 0) { this.broadcast({ type: "queue_update", queue: [] }) @@ -1367,8 +1642,10 @@ class LoopSession { } async interrupt() { - this.generating = false + const codexWasRunning = this.codexProc !== null + if (!codexWasRunning) this.generating = false if (this.q) await this.q.interrupt().catch(() => {}) + this.stopCodexProcess() } /** Background in-flight foreground tasks (Bash commands + subagents) so the @@ -1403,6 +1680,7 @@ class LoopSession { /** Tear down the SDK process and disconnect all subscribers. Used when a * loop is archived so no orphaned processes remain. */ async destroy() { + this.destroyed = true this.cancelIdleCleanup() this.generating = false this.queueProcessing = false @@ -1417,6 +1695,8 @@ class LoopSession { try { await this.q.interrupt() } catch {} this.q = null } + this.stopCodexProcess() + this.codexRuntime = null for (const [, pending] of this.pendingQuestions) { pending.reject(new Error("loop archived")) } @@ -1522,6 +1802,8 @@ class LoopSession { try { await this.q.interrupt() } catch {} this.q = null } + this.stopCodexProcess() + await rm(this.codexThreadPath(), { force: true }).catch(() => {}) // 2. Drop SDK context without deleting history. Touch an empty new // jsonl in each existing encoded-cwd subdir so --continue picks it. // If no subdir exists yet (no SDK has spawned in this loop), the diff --git a/server/test/codex-cli.test.ts b/server/test/codex-cli.test.ts new file mode 100644 index 00000000..4c88302e --- /dev/null +++ b/server/test/codex-cli.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test" +import { + buildCodexEnv, + buildCodexExecArgs, + codexCompletedItem, + codexEventError, + codexTurnUsage, + parseCodexEvent, +} from "../src/codex-cli" + +describe("Codex CLI adapter", () => { + test("builds fresh and resumed commands for non-git workspaces", () => { + expect(buildCodexExecArgs({ workdir: "/tmp/loop" })).toEqual([ + "exec", + "--json", + "--sandbox", + "workspace-write", + "--cd", + "/tmp/loop", + "--skip-git-repo-check", + "-", + ]) + + expect(buildCodexExecArgs({ + workdir: "/tmp/loop", + threadId: "thread-id", + modelArg: "gpt-test", + })).toEqual([ + "exec", + "resume", + "--json", + "--skip-git-repo-check", + "-m", + "gpt-test", + "thread-id", + "-", + ]) + }) + + test("builds an ephemeral read-only connection probe", () => { + expect(buildCodexExecArgs({ + workdir: "/tmp/workspace", + modelArg: "gpt-test", + sandbox: "read-only", + ephemeral: true, + })).toEqual([ + "exec", + "--json", + "--sandbox", + "read-only", + "--ephemeral", + "--cd", + "/tmp/workspace", + "--skip-git-repo-check", + "-m", + "gpt-test", + "-", + ]) + }) + + test("builds an isolated provider environment", () => { + expect(buildCodexEnv({ + apiKey: "test-key", + baseUrl: "https://example.test/v1/", + }, { PATH: "/bin" })).toEqual({ + PATH: "/bin", + CODEX_API_KEY: "test-key", + OPENAI_API_KEY: "test-key", + CODEX_BASE_URL: "https://example.test/v1", + OPENAI_BASE_URL: "https://example.test/v1", + }) + }) + + test("parses completed items and usage", () => { + const item = parseCodexEvent(JSON.stringify({ + type: "item.completed", + item: { type: "agent_message", text: "done" }, + })) + expect(item && codexCompletedItem(item)).toEqual({ type: "agent_message", text: "done" }) + + const completed = parseCodexEvent(JSON.stringify({ + type: "turn.completed", + usage: { input_tokens: 10, output_tokens: 2, cached_input_tokens: 7 }, + })) + expect(completed && codexTurnUsage(completed)).toEqual({ + inputTokens: 10, + outputTokens: 2, + cachedInputTokens: 7, + }) + }) + + test("extracts nested API failures from stdout JSONL", () => { + expect(codexEventError({ + type: "turn.failed", + error: { + message: JSON.stringify({ + type: "error", + status: 400, + error: { message: "The model requires a newer version of Codex." }, + }), + }, + })).toBe("The model requires a newer version of Codex.") + expect(codexEventError({ type: "error", message: "authentication failed" })) + .toBe("authentication failed") + expect(codexEventError({ type: "turn.completed" })).toBeNull() + expect(parseCodexEvent("not json")).toBeNull() + }) +}) diff --git a/web/src/api.ts b/web/src/api.ts index e1f59935..4a9b0452 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1091,7 +1091,8 @@ export async function listTopics(): Promise { } export type ModelEntry = { id: string; maxContextTokens?: number } -export type ProviderInfo = { model?: string; models: ModelEntry[]; baseUrl: string; source: "personal" | "workspace"; enabled: boolean; hasKey: boolean } +export type ProviderRuntime = "claude" | "codex" +export type ProviderInfo = { model?: string; models: ModelEntry[]; baseUrl: string; runtime?: ProviderRuntime; source: "personal" | "workspace"; enabled: boolean; hasKey: boolean } export type ProvidersResponse = { providers: Record; default: string } export async function getProviders(): Promise { const r = await apiFetch("/api/providers") @@ -1105,8 +1106,10 @@ export async function testProviderConnection( model: string, provider?: string, source?: "personal" | "workspace", + runtime?: ProviderRuntime, ): Promise<{ ok: boolean; error?: string }> { const body: Record = { baseUrl, model } + if (runtime) body.runtime = runtime if (apiKey) { body.apiKey = apiKey } else if (provider && source) { @@ -1270,6 +1273,7 @@ export type SettingsProvider = { model?: string models: ModelEntry[] baseUrl: string + runtime?: ProviderRuntime hasKey?: boolean enabled: boolean apiKey?: string @@ -1284,7 +1288,7 @@ export type PersonalSettings = { } export type WorkspaceSettings = { - providers: Record + providers: Record default: string tokenUsage: TokenUsage } @@ -1311,6 +1315,7 @@ export async function updatePersonalSettings(patch: { export type ProviderDisk = { models?: ModelEntry[] baseUrl: string + runtime?: ProviderRuntime /** Plain string; may contain `${VAR}` references resolved against vault envs/ at load. */ apiKey?: string enabled?: boolean @@ -1369,7 +1374,7 @@ export async function getWorkspaceSettings(): Promise { } export async function updateWorkspaceSettings(patch: { - providers?: Record + providers?: Record default?: string }): Promise { const r = await apiFetch("/api/settings/workspace", { @@ -1407,16 +1412,17 @@ export async function getLoopTokenUsage(): Promise { // ── admin presets ── -export type ProviderPresetModel = string | { id: string; tier?: "opus" | "sonnet" | "haiku" } +export type ProviderPresetModel = string | { id: string; tier?: "opus" | "sonnet" | "haiku"; maxContextTokens?: number } export type ProviderPreset = { name: string baseUrl: string + runtime?: ProviderRuntime models: ProviderPresetModel[] } /** Normalize a preset model entry to { id, tier? }. */ -export function normalizePresetModel(m: ProviderPresetModel): { id: string; tier?: "opus" | "sonnet" | "haiku" } { +export function normalizePresetModel(m: ProviderPresetModel): { id: string; tier?: "opus" | "sonnet" | "haiku"; maxContextTokens?: number } { if (typeof m === "string") return { id: m } return m } diff --git a/web/src/components/chat/ModelSelector.tsx b/web/src/components/chat/ModelSelector.tsx index 753b1f36..9759be88 100644 --- a/web/src/components/chat/ModelSelector.tsx +++ b/web/src/components/chat/ModelSelector.tsx @@ -31,7 +31,8 @@ export default function ModelSelector() { if (!providers) return []; const result: FlatModel[] = []; for (const [provName, info] of Object.entries(providers.providers)) { - if (info.enabled === false || !info.hasKey) continue; + if (info.enabled === false) continue; + if (info.runtime !== "codex" && !info.hasKey) continue; for (const m of info.models ?? []) { const q = search.toLowerCase().trim(); diff --git a/web/src/components/dialog/AdminDialog.tsx b/web/src/components/dialog/AdminDialog.tsx index 4c1c6ddb..8f3cd2d0 100644 --- a/web/src/components/dialog/AdminDialog.tsx +++ b/web/src/components/dialog/AdminDialog.tsx @@ -21,6 +21,7 @@ import { type AdminUser, type WorkspaceSettings, type ModelEntry, + type ProviderRuntime, type ProviderPreset, } from "@/api" @@ -245,6 +246,7 @@ type WorkspaceDraft = { providers: Record 0 ? { maxContextTokens: m.maxContextTokens } : {}), })) ?? [], baseUrl: prov.baseUrl ?? "", + runtime: (prov as any).runtime === "codex" ? "codex" : "claude", apiKey: "", keyDirty: false, hasKey: prov.hasKey ?? false, @@ -394,7 +397,7 @@ export function WorkspacePanel() { setDraft((d) => { if (!d) return d return { ...d, providers: { ...d.providers, [n]: { - models: [], baseUrl: "", apiKey: "", keyDirty: false, hasKey: false, enabled: false, + models: [], baseUrl: "", runtime: "claude", apiKey: "", keyDirty: false, hasKey: false, enabled: false, } } } }) setNewName("") @@ -417,6 +420,7 @@ export function WorkspacePanel() { out[name] = { models, baseUrl: p.baseUrl, + runtime: p.runtime, enabled: p.enabled, } if (p.keyDirty && p.apiKey.trim()) out[name].apiKey = p.apiKey.trim() @@ -449,7 +453,7 @@ export function WorkspacePanel() { {names.map((name) => { const p = draft.providers[name] const isAddingModel = addingModel[name] ?? false - const hasKey = p.hasKey || p.apiKey.trim() !== "" + const hasKey = p.runtime === "codex" || p.hasKey || p.apiKey.trim() !== "" return (
{/* Provider header */} @@ -499,6 +503,16 @@ export function WorkspacePanel() { {/* Fields */}
+ + + { const newKey = p.apiKey.trim() const tk = `${name}::${m.id}` - if (!newKey && !p.hasKey) { + if (p.runtime !== "codex" && !newKey && !p.hasKey) { setTestingModel((t) => ({ ...t, [tk]: "error" })) setTestError((t) => ({ ...t, [tk]: "enter an API key first" })) setTimeout(() => { @@ -619,8 +633,8 @@ export function WorkspacePanel() { setTestError((t) => ({ ...t, [tk]: "" })) try { const result = newKey - ? await testProviderConnection(p.baseUrl, newKey, m.id) - : await testProviderConnection(p.baseUrl, "", m.id, name, "workspace") + ? await testProviderConnection(p.baseUrl, newKey, m.id, undefined, undefined, p.runtime) + : await testProviderConnection(p.baseUrl, "", m.id, name, "workspace", p.runtime) setTestingModel((t) => ({ ...t, [tk]: result.ok ? "ok" : "error" })) if (!result.ok) setTestError((t) => ({ ...t, [tk]: result.error ?? "unknown error" })) } catch (e: any) { @@ -632,15 +646,15 @@ export function WorkspacePanel() { setTestError((t) => { const { [tk]: _, ...rest } = t; return rest }) }, 4000) }} - disabled={tmState === "testing" || (!p.hasKey && !p.apiKey.trim())} + disabled={tmState === "testing" || (p.runtime !== "codex" && !p.hasKey && !p.apiKey.trim())} className={`shrink-0 text-[9px] px-1 py-0 rounded transition-colors ${ - !p.hasKey && !p.apiKey.trim() ? "opacity-0 group-hover:opacity-100 text-gray-300" : + p.runtime !== "codex" && !p.hasKey && !p.apiKey.trim() ? "opacity-0 group-hover:opacity-100 text-gray-300" : tmState === "ok" ? "bg-emerald-100 text-emerald-700" : tmState === "error" ? "bg-red-100 text-red-700" : tmState === "testing" ? "bg-gray-100 text-gray-400 animate-pulse" : "text-gray-400 hover:text-gray-700 opacity-0 group-hover:opacity-100" }`} - title={tmErr || (p.apiKey.trim() ? "test connection" : p.hasKey ? "test connection" : "enter an API key first")} + title={tmErr || (p.runtime === "codex" ? "test Codex CLI" : p.apiKey.trim() ? "test connection" : p.hasKey ? "test connection" : "enter an API key first")} > {tmState === "ok" ? "OK" : tmState === "error" ? "FAIL" : tmState === "testing" ? "..." : "test"} @@ -676,12 +690,16 @@ export function WorkspacePanel() { providers: { ...d.providers, [p.name]: { - models: p.models.map((m) => { const n = normalizePresetModel(m); return { id: n.id } }), + models: p.models.map((m) => { + const n = normalizePresetModel(m) + return { id: n.id, ...(n.maxContextTokens ? { maxContextTokens: n.maxContextTokens } : {}) } + }), baseUrl: p.baseUrl, + runtime: p.runtime === "codex" ? "codex" : "claude", apiKey: "", keyDirty: false, hasKey: false, - enabled: false, + enabled: p.runtime === "codex", } satisfies WorkspaceDraft["providers"][string], }, } diff --git a/web/src/components/settings/PresetsPanel.tsx b/web/src/components/settings/PresetsPanel.tsx index 87551e0d..04bb50bd 100644 --- a/web/src/components/settings/PresetsPanel.tsx +++ b/web/src/components/settings/PresetsPanel.tsx @@ -112,6 +112,7 @@ function ProviderPresetsEditor({ const [adding, setAdding] = useState(false) const [newName, setNewName] = useState("") const [newBaseUrl, setNewBaseUrl] = useState("") + const [newRuntime, setNewRuntime] = useState<"claude" | "codex">("claude") const [newModels, setNewModels] = useState("") const add = () => { @@ -120,8 +121,8 @@ function ProviderPresetsEditor({ const models = newModels.trim() ? newModels.split("\n").map(s => s.trim()).filter(Boolean) : [] - onChange([...presets, { name: n, baseUrl: newBaseUrl.trim(), models }]) - setNewName(""); setNewBaseUrl(""); setNewModels(""); setAdding(false) + onChange([...presets, { name: n, baseUrl: newBaseUrl.trim(), runtime: newRuntime, models }]) + setNewName(""); setNewBaseUrl(""); setNewRuntime("claude"); setNewModels(""); setAdding(false) } const remove = (idx: number) => { @@ -144,6 +145,7 @@ function ProviderPresetsEditor({ Name + Runtime Base URL Models @@ -160,6 +162,17 @@ function ProviderPresetsEditor({ className={inputClassSm} /> + + + +