Omniscio documentation
Browse all documentation
  1. Getting Started13
  2. Sessions & Agents115
  3. Inbox & Notifications59
  4. Projects & Tasks95
  5. Automation & Scheduling75
  6. Knowledge & Memory26
  7. AI Features60
  8. Integrations100
  9. Plugins & Marketplace33
  10. Cloud & Teams56
  11. Settings & Customization58
  12. Account & Billing28
  13. Troubleshooting84
  14. CLI & API Reference22
  15. Legal & Policies4
  16. Uncategorised22

Codex provider (OpenAI CLI-backed sessions) (part 3)

Part 3 of the Codex provider page: what actually runs when you start a Codex session, how Omniscio talks to it and streams a turn back, and where the pieces live in the codebase.

What it is

This is part 3 of the Codex provider (OpenAI CLI-backed sessions) 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, 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_CHANGED 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: applyQuickReplyEffects() (records quick-reply usage; src/main/services/quick/quick-reply-effects.ts), 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 applyQuickReplyEffects 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 (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). 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. Full invariants: 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 (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 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 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 stamps it at create-with-prompt time via setSessionCodexAccountId, and 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, queries-codex-accounts.test.ts, codex-account-home.test.ts, codex-account-service.test.ts, codex-session-manager.test.ts (per-account quota+cost routing), codex-session-flow.test.ts (stamp + isolated CODEX_HOME), session-handlers-codex.test.ts, account-settings-codex.test.tsx, CodexAccountRow.test.tsx, account-indicator-codex-tab.test.tsx. Full invariant table: 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 — multi-turn orchestration; one CodexAppServerClient per session
  • 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 — binary discovery on PATH
  • src/main/services/openai-credential-store.ts — encrypted API-key storage + slot-scoped codex login launcher
  • src/main/db/queries-codex-accounts.ts — codex_accounts CRUD (managed-account rows)
  • 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 — isolated CODEX_HOME layout + file-backed-auth config.toml
  • src/renderer/src/features/settings/sections/accounts/CodexAccountsTab.tsx — Settings → Accounts → Codex tab
  • src/renderer/src/components/ui/account/CodexAccountRow.tsx — shared managed-account row (Settings + popover)
  • src/renderer/src/components/ui/account/CodexAccountsPopoverTab.tsx — lighter Codex mirror in the lower-left popover
  • 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 — sidebar "+ New Session" button (spawns the project default; no provider picker)
  • src/renderer/src/features/sessions/ChangeProviderButton.tsx — per-launch provider override on a fresh session header
  • src/renderer/src/features/sessions/ProviderBadge.tsx — Codex icon in session header
  • 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 — 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) page, and the accounts, per-project default and model chips are in part 2. The behaviour this code must hold to is written up in the Codex robustness contract, and the wider engine registry it plugs into is in the provider registry contract.

Last verified 2026-10-06