---
title: Codex provider (OpenAI CLI-backed sessions) (part 3)
---

# Codex provider (OpenAI CLI-backed sessions) (part 3)

## What it is

This is part 3 of the [Codex provider (OpenAI CLI-backed sessions)](codex-provider.md) page. It covers what Omniscio actually starts when a Codex session runs, how the two talk to each other, how a turn is streamed back and folded into readable prose, and which parts of the codebase are involved.

## Where to find it

Nothing described here has a screen of its own — it is the machinery behind the Codex sessions you already see in the sidebar. The one visible part, the engine mark in a session header, is covered on the [parent page](codex-provider.md), and the rest is listed at the end of this page as the files involved.

## How it behaves

From the outside a Codex session behaves like any other session in Omniscio: the same statuses, the same permission prompt in the same pane, the same per-session cost and token counters, and the same archive, pause and snooze controls. What differs is what runs under it — instead of a Claude process, Omniscio starts OpenAI's own Codex program and speaks to it directly, so a turn can fail in ways that are Codex's rather than Claude's. The notes below are for anyone maintaining that plumbing.

## For agents

### How it works under the hood

A Codex session is a long-lived child process per `sessionId` — Omniscio spawns the `codex` binary in app-server mode, communicates with it over stdin / stdout JSON-RPC, and translates its events into Omniscio's existing `SESSION_OUTPUT` / `SESSION_STATUS_UPDATED` push contracts. Multi-turn is native — same process handles every turn, no `--resume` needed during the session's lifetime. When Omniscio quits, the child process dies (Windows: via the orphan-kill Job Object; macOS / Linux: kernel reparents to init). On Windows the app-server launches only INSIDE that job — the job-gate launcher holds it until Omniscio has put it there — and a launch that cannot be contained is refused, so the session shows "Failed to start Codex session: Windows process-tree ownership …" instead of running an agent the app could not end. A forced stop kills the whole process tree, not just the launcher Omniscio holds.

Routing happens in `session-handlers.ts`: when a `SESSION_LAUNCH` IPC arrives with `provider: 'codex'`, it goes to `codexSessionManager.launch()` instead of the normal Claude `processManager.launch()`. The session row stores `provider: 'codex'` in the database column; the renderer's `ProviderBadge` component reads that column and renders the Codex icon.

**Provider-parity contract in `session-service.ts`.** Each provider's `SESSION_SEND_RESPONSE` branch (Claude, Codex, OpenClaw, Gemini) ends with the same four side-effect calls right after the manager's `sendMessage` returns successfully: `applySnippetEffects()` (records snippet usage), `titleOrchestrator.maybeGenerate()` (first-operator-message title rewrite, provider-agnostic), `coachEvent('session:send:manual')` (workflow-coach signal), and `trackEvent('session', 'message_sent', ...)` (feature analytics). Each branch captures `priorOperatorCount = getOperatorMessageCount(sessionId)` BEFORE persistence so `applySnippetEffects` can tell "first send in session" from "subsequent send". A new provider added to this file MUST wire the same block — leaving it out reproduces the 2026-05-13 bugs where Codex sessions never got renamed past their `"Session N,NNN"` placeholders.

**Streaming-delta payload contract.** Renderer state in `session-store.ts` treats every `SESSION_OUTPUT` event with `streaming: true` as an **incremental chunk** to append (`existing.content + text`); events with `streaming: false` carry the full final text and **replace** the message. `codex-session-manager.ts` accumulates the running text internally for its own bookkeeping (so the final non-streaming flush carries the full message after a stream completes), but the per-delta `SESSION_OUTPUT` event MUST emit only the chunk that just arrived — not the accumulator. Sending the accumulator on `streaming: true` makes the renderer concatenate twice ("II'mI'm checkingI'm checking…"). Same contract applies to every provider that surfaces streaming bubbles.

**Agent-message segment buffering + de-interleave.** Codex streams narration as `item/agentMessage/delta` events tagged with an `itemId`. `codex-app-server-client.ts` buffers each item's deltas separately — a per-`itemId` map, NOT one shared buffer — and forwards each as ONE contiguous paragraph at a real boundary (a tool marker, or turn end), joining distinct items with a blank line and dropping a re-emitted final block (the duplicate-answer dedup). Buffering PER item is what keeps two agentMessage items whose deltas **interleave** on the wire (Codex v0.141.0 sometimes streams parallel narration in one turn) from being flushed on every itemId flip — which used to drop a blank line between every fragment and render the turn as a vertical tower of one word per line (the word-salad bug). It's de-interleaved at the source, so the fix reaches every surface; rows already stored with the old salad are additionally de-verticalized at DISPLAY time in `agent-markdown-content.ts` (`collapseInterleaveSalad`, a proven no-op on clean content — no stored data is rewritten). Interleaved words already merged into one row can't be un-scrambled, only de-verticalized. Full invariant + the tests that lock it: [codex-robustness-contract.md](../../.claude/memory/contracts/codex-robustness-contract.md) (`agentmessage-segment-deinterleave`).

**Tool-activity markers (`item/completed`).** The Codex app-server protocol reports each tool action as an `item/completed` JSON-RPC notification carrying a `ThreadItem` (`commandExecution`, `fileChange`, `mcpToolCall`, `dynamicToolCall`, `collabAgentToolCall`, `webSearch`, `imageView`, `imageGeneration`). `codex-app-server-client.ts` maps each of those to a Claude-shaped marker pair (`formatCodexItemMarker`) and emits it through the same `eventHandler` → `streamingAccumulator` path the prose flows through: a `▸ <call>` line at column 0, a two-space markdown hard break, then a visually-nested `← <result>`, the whole block prefixed with `\n\n` to separate it from the surrounding narration. The emitted text is byte-for-byte the marker format Claude's NDJSON formatter produces, so the **finalized agent message is indistinguishable from Claude's** — the existing shared classifier (`extractProse` at finalize INSERT, `classifyAgentRows` in the renderer) folds it with **zero change to any shared code**. Items that are prose or non-activity — `agentMessage` (it IS the narration the classifier folds against), `userMessage`, `reasoning`, `plan`, review-mode toggles, `contextCompaction` — return `null` and emit no marker, so they never paint empty activity rows. **`contextCompaction` is the one exception that is still routed somewhere:** it is not tool activity, but it IS a real event the user must see, so the client hands both its `item/started` and its `item/completed` to a separate compaction seam (`onCompaction`) and the session manager turns them into the unkinded “Compacting conversation…” indicator row and the settled `compact-divider` row — the exact pair Claude's stream-json `compact_boundary` path emits. (Codex's protocol also has a legacy `context_compacted` notification; its own schema marks that **deprecated in favour of the `ContextCompaction` item type**, so Omniscio reads the item.) Markers fire on `item/completed` only (the `item/started` notification is a deliberate no-op), because completion is what carries the exit code / file count / status the `←` result line needs, and emitting the `▸`+`←` pair together guarantees the pair is never split or left orphaned. Detail strings (commands, paths, queries) collapse interior whitespace and clip to 80 chars + `…`. **A command can finish after its turn.** Codex's unified exec lets a background command outlive the turn that started it, and its `item/completed` then arrives after `turn/completed`. The client remembers which tool items were still running when each thread's turn completed and drops their late completion whole — no marker and no tool activity — because no turn is left to close the reply it would start. Anything else left streaming is still caught by the shared send path, which seals an open stream as its own background turn instead of wiping it ([postmortem](../../.claude/memory/postmortems/external-engine-wiped-stream-ghost-card-postmortem.md)). No label's first token is the literal "Extended" — that token is Claude's extended-thinking sentinel, which `countToolMarkers` excludes from the action count.

**Execution sandbox & approval handshake (2026-06-06).** Omniscio spawns the app-server with `-c sandbox_mode=danger-full-access` so the agent runs with full OS permissions (a Claude child has no sandbox either); Codex's default `workspace-write` blocked `.git/` writes. Approval policy is `untrusted`, resolved from `resolveCodexExecPolicy` (env-overridable — never the old hardcoded `never`). When Codex needs approval it sends an `item/commandExecution/requestApproval` / `item/fileChange/requestApproval` server-request; `codex-app-server-client.ts` answers with the exact `{ decision }` envelope (v2 `accept`/`decline`/…, v1 `approved`/`denied` translated), **fail-closed** on any error or missing handler. `codex-session-manager.ts` turns the request into Omniscio's shared permission UI — a `SESSION_PERMISSION_REQUEST` push + `needs_you` / `permission_request` status — blocks the turn until the user responds, and settles any pending approval with `cancel` on teardown so a dead session never leaves Codex hanging. `SESSION_PERMISSION_RESPOND` is provider-routed back to `codexSessionManager.respondToApproval`. The other CLI providers (Gemini, Cursor, OpenCode, Anti-Gravity) already run full-access + auto-approve and have no interceptable approval protocol, so prompts there are future work.

**Approval routing + user-selectable policy (2026-06-07).** Those approval server-requests are routed by id **presence**, not type: Codex numbers its requests (`RequestId = string | number`), and Omniscio's original `typeof id === 'string'` gate dropped numeric-id approvals into the notification handler — they were silently discarded and the turn blocked until the ~900s stall watchdog, the real "Codex is unreliable" bug. `handleMessage` now classifies by JSON-RPC 2.0 framing (`id`+`method` → request, `id` → response, `method` → notification). The permission level is also user-selectable: the `codexApprovalMode` setting drives `resolveCodexExecPolicy`'s `EXEC_POLICY_BY_MODE` table, which sets BOTH sandbox + approval per level — `'ask'` → `danger-full-access` + `untrusted`, `'ask-on-request'` → `workspace-write` + `on-request` (restricted on purpose, so on-request can actually escalate; under full-access it never prompts), `'auto'` → `danger-full-access` + `never`. So the sandbox is **level-dependent**, not always full-access; bound at thread start. **Since 2026-09-23 the default is `'inherit'`**: `resolveCodexExecPolicy(env, mode, agentPermissionLevel)` maps the shared Agent Permission Level through `EXEC_POLICY_BY_AGENT_PERMISSION` onto the same table — `read_only`/`guarded` → `ask`, `autonomous` → `ask-on-request`, `full` → `auto` — and the explicit modes stay as per-provider overrides. `migrate-codex-approval-mode-to-inherit` moves a stored `'ask'` (the old default, indistinguishable from a choice) or a missing value to `inherit` once, keeping `ask-on-request`/`auto`. The `workspace-write` sandbox has no network (verified with `codex sandbox` on 0.154.0: a `curl` fails to resolve until `sandbox_workspace_write.network_access=true`), so an inherited Autonomous policy carries `networkAccess` and the client adds `-c sandbox_workspace_write.network_access=true` (via `execPolicyConfigOverrides`), matching Claude's Autonomous; the explicit `ask-on-request` override stays offline, so there a network command escalates and prompts. (`on-failure` is intentionally omitted — Codex's sandbox isn't enforced on Windows, so it never escalates: a dead option.) See [codex-approval-numeric-id-routing postmortem](../../.claude/memory/postmortems/codex-approval-numeric-id-routing-postmortem.md). Full invariants: [provider-registry-contract.md](../../.claude/memory/contracts/provider-registry-contract.md).

**Managed Codex accounts under the hood (2026-06-13).** The accounts feature is a small parallel stack, deliberately kept off the Anthropic account path:

- **Data:** a `codex_accounts` table (one row per managed login: `id`, `email`, `plan_type`, `codex_home`, `is_active`, `status`, `last_error`, `last_refreshed_at`, `rate_limits_json`, timestamps) plus a nullable `sessions.codex_account_id` column, both added by the dated ledger migration `20260614031836-add-codex-accounts-table-and-sessions-codex-account-id.ts` (guarded `CREATE TABLE IF NOT EXISTS` + `PRAGMA table_info` ALTER, so a dump/restore is safe). CRUD lives in [queries-codex-accounts.ts](../../src/main/db/queries-codex-accounts.ts) (`listCodexAccounts` / `getCodexAccountById` / `insertCodexAccount` / `setActiveCodexAccount` (single transaction — flips others off) / `removeCodexAccount` (hard DELETE, not soft) / `updateCodexAccountUsage` / `updateCodexAccountIdentity` / `updateCodexAccountError`); `db` is a required explicit parameter. **`codex_home` is WRITTEN but never READ as truth: the home is DERIVED on every read** as `<baseDir>/codex-accounts/<account id>`, so both read functions also take `baseDir` as a required parameter. That is deliberate — the column holds an absolute path, and when the app-data folder was renamed (`agent-mission-control` → `omniscio`) every stored path pointed at a directory that no longer existed, which left accounts unrefreshable, unremovable, and broken for new sessions. Deriving from the id makes the stored value unable to go stale, and required parameters mean no future caller can silently reintroduce it. Grok's `grok_home` works identically. `codex_account_id` is **parallel** to `account_id` — it is NEVER reused for Anthropic state, and a Codex session never carries `account_id` (`usesAnthropicAccount('codex')` is false; the-account-predicate-is-one-and-registry-driven).
- **Isolated home:** [codex-account-home.ts](../../src/main/services/engines/codex-account-home.ts) is a pure, electron-free helper — `computeCodexHome(baseDir, id)` → `<userData>/codex-accounts/<id>`, and `writeAccountCodexConfig` MERGES `cli_auth_credentials_store = "file"` into that slot's `config.toml` (line-based, idempotent, preserves unrelated keys; fs errors humanized via `friendlyFsError`). [codex-account-service.ts](../../src/main/services/engines/codex-account-service.ts) orchestrates over the queries + that helper: `createCodexAccountSlot` builds the home+config BEFORE inserting the row (no orphan row on fs failure) and makes the FIRST slot active. None of this reads or writes `~/.codex`.
- **Routing (Omniscio picks the active account):** BOTH stamp points resolve the same way — `<session's stamped id> ?? listCodexAccounts().find(a => a.isActive)?.id ?? null`. The launch gate in [launch.ts](../../src/main/ipc/session/launch.ts) stamps it at create-with-prompt time via `setSessionCodexAccountId`, and [codex-session-manager.ts](../../src/main/services/engines/codex-session-manager.ts) `launch` resolves + **persists** it again at spawn — so a blank empty-state session (which never goes through the launch gate) still runs under the active account instead of falling back to the global login. It then resolves the slot's `codexHome` via `getCodexAccountById` and passes it to `client.start(model, effort, codexHome)` which injects `CODEX_HOME` into the spawn env; it stays `null` (global `~/.codex`) only when there are no managed accounts at all. Live `account/rateLimits/updated` snapshots route to `updateCodexAccountUsage(db, codexAccountId, …)` (additive — the pool-wide cache the account pill reads is still updated), and per-turn cost rows attribute to `session.codexAccountId ?? CODEX_SYNTHETIC_ACCOUNT_ID` (`'openai-shared'`) with `source: 'codex'` so both legacy and per-account rows stay in the one source-scoped rollup.
- **No per-session picker (2026-06-16):** the pre-first-message "Codex account" picker and its DB-only `SESSION_SET_CODEX_ACCOUNT` IPC were **removed** — Codex accounts are now managed like Claude accounts, Omniscio picks the active one (resolved at spawn, above). The `sessions.codex_account_id` column, the `Session.codexAccountId` field, and the launch-gate stamp all stay; only the per-session UI override is gone.
- **Tests:** [codex-accounts-migration.test.ts](../../tests/integration/codex-accounts-migration.test.ts), [queries-codex-accounts.test.ts](../../tests/unit/db/queries/queries-codex-accounts.test.ts), [codex-account-home.test.ts](../../tests/unit/services/codex-account-home.test.ts), [codex-account-service.test.ts](../../tests/unit/services/codex-account-service.test.ts), [codex-session-manager.test.ts](../../tests/unit/codex-session-manager.test.ts) (per-account quota+cost routing), [codex-session-flow.test.ts](../../tests/integration/codex-session-flow.test.ts) (stamp + isolated `CODEX_HOME`), [session-handlers-codex.test.ts](../../tests/unit/session-handlers-codex.test.ts), [account-settings-codex.test.tsx](../../tests/unit/features/settings/account-settings-codex.test.tsx), [CodexAccountRow.test.tsx](../../tests/unit/renderer/account/CodexAccountRow.test.tsx), [account-indicator-codex-tab.test.tsx](../../tests/unit/components/account-indicator-codex-tab.test.tsx). Full invariant table: [provider-registry-engines-core-codex-persistent-external-contract.md](../../.claude/memory/contracts/provider-registry-engines-core-codex-persistent-external-contract.md) — the managed-account rules, from `a-managed-account-is-an-isolated-home` through `balancing-is-entitled-and-off-by-default`.

Telemetry: every successful non-Claude spawn fires the `spawn_non_claude_session` feature event, recording `{ provider }` only — no session ID, project ID, or prompt content. This lets the Stats view answer "how often am I using Codex vs Claude vs Gemini" without exposing any user content.

### Files

- [src/main/services/engines/codex-session-manager.ts](../../src/main/services/engines/codex-session-manager.ts) — multi-turn orchestration; one `CodexAppServerClient` per session
- [src/main/services/engines/codex-app-server-client.ts](../../src/main/services/engines/codex-app-server-client.ts) — JSON-RPC over stdin / stdout, lifecycle, event translation
- [src/main/services/engines/codex-binary-resolver.ts](../../src/main/services/engines/codex-binary-resolver.ts) — binary discovery on PATH
- [src/main/services/openai-credential-store.ts](../../src/main/services/openai-credential-store.ts) — encrypted API-key storage + slot-scoped `codex login` launcher
- [src/main/db/queries-codex-accounts.ts](../../src/main/db/queries-codex-accounts.ts) — `codex_accounts` CRUD (managed-account rows)
- [src/main/services/engines/codex-account-service.ts](../../src/main/services/engines/codex-account-service.ts) — managed-account orchestration (create slot / list / switch / remove / refresh)
- [src/main/services/engines/codex-account-home.ts](../../src/main/services/engines/codex-account-home.ts) — isolated `CODEX_HOME` layout + file-backed-auth `config.toml`
- [src/renderer/src/features/settings/sections/accounts/CodexAccountsTab.tsx](../../src/renderer/src/features/settings/sections/accounts/CodexAccountsTab.tsx) — Settings → Accounts → Codex tab
- [src/renderer/src/components/ui/account/CodexAccountRow.tsx](../../src/renderer/src/components/ui/account/CodexAccountRow.tsx) — shared managed-account row (Settings + popover)
- [src/renderer/src/components/ui/account/CodexAccountsPopoverTab.tsx](../../src/renderer/src/components/ui/account/CodexAccountsPopoverTab.tsx) — lighter Codex mirror in the lower-left popover
- [src/main/services/provider-setup/provider-readiness.ts](../../src/main/services/provider-setup/provider-readiness.ts) — single source of truth for the 3-gate readiness check (used by ChangeProviderButton, Edit Project radios, project-row cue, spawn guard)
- [src/renderer/src/features/sessions/ProviderSplitButton.tsx](../../src/renderer/src/features/sessions/ProviderSplitButton.tsx) — sidebar "+ New Session" button (spawns the project default; no provider picker)
- [src/renderer/src/features/sessions/ChangeProviderButton.tsx](../../src/renderer/src/features/sessions/ChangeProviderButton.tsx) — per-launch provider override on a fresh session header
- [src/renderer/src/features/sessions/ProviderBadge.tsx](../../src/renderer/src/features/sessions/ProviderBadge.tsx) — Codex icon in session header
- [src/renderer/src/features/dashboard/ProjectRowDefaultProviderCue.tsx](../../src/renderer/src/features/dashboard/ProjectRowDefaultProviderCue.tsx) — `ProjectRowDefaultProviderCue` (the "!" badge, composed inline by the project row in `ProjectListItem.tsx`)
- [src/renderer/src/features/projects/EditProjectDialog.tsx](../../src/renderer/src/features/projects/EditProjectDialog.tsx) — per-project default radios

## Related

The user-facing half of Codex — switching it on, signing in and what a session looks like — is on the [Codex provider (OpenAI CLI-backed sessions)](codex-provider.md) page, and the accounts, per-project default and model chips are in [part 2](codex-provider-part-2.md). The behaviour this code must hold to is written up in the [Codex robustness contract](../../.claude/memory/contracts/codex-robustness-contract.md), and the wider engine registry it plugs into is in the [provider registry contract](../../.claude/memory/contracts/provider-registry-contract.md).
