Skip to content

feat(codex): drive, resume & answer Codex over the app-server JSON-RPC transport#6

Merged
timhanlon merged 26 commits into
mainfrom
feat/codex-app-server-driver
Jul 5, 2026
Merged

feat(codex): drive, resume & answer Codex over the app-server JSON-RPC transport#6
timhanlon merged 26 commits into
mainfrom
feat/codex-app-server-driver

Conversation

@timhanlon

Copy link
Copy Markdown
Owner

Adds OpenAI Codex's app-server (a long-lived JSON-RPC 2.0 process over stdio — the interface the Codex VS Code extension is built on) as a third live driver transport, alongside the existing rollout-file scraper and PTY TUI launch. You can now launch, message, approve, resume, and stop a Codex session driven directly over the typed protocol — with its turns landing in the same chat timeline as every other provider.

Why

The rollout-file scraper is post-hoc and lossy (it reverse-parses a telemetry preamble out of tool-result text). app-server gives a typed, bidirectional, streaming control protocol: first-class commandExecution fields, real server→client approval requests, and token usage — no scraping. It's additive, not a replacement: live driving and post-hoc ingest stay complementary, writing to one store and one projection.

Architecture

Three quarantined layers under src/main/ingest/providers/codex-appserver/:

  • transport — generic newline-delimited JSON-RPC over a child process; knows no Codex strings. Fails fast on child exit (no hangs).
  • driver — the only file that knows the protocol: initialize → thread/start (or thread/resume), folds item/*/turn/* notifications, runs the approval state machine, normalizes to the shared ExtractedRows. Every payload is Schema-decoded.
  • launch — persists each turn into the shared IngestStore, so a live-driven turn and a scraped turn render identically.

Session runtime splitTargetSession gained a second live runtime:

  • RpcSessionManager — runtime owner for app-server sessions (sibling of the PTY TargetSessionManager): serializes turns, tracks a generating marker.
  • SessionRuntimeRouter — the one door: dispatches launch by intent (runtime: pty | rpc), submit/stop by ownership. Assembles the unified session list.
  • Detached sessions are runtime-neutral — the PTY store holds only live PTYs; boot-restore no longer seeds it. The unified list is live-pty ∪ live-rpc ∪ (persisted rows not live), so an id is live in exactly one manager or detached — never both. Resuming launches it into a runtime; no cross-manager ownership handoff. A Codex session is transport-agnostic: the same detached row resumes over either PTY (codex resume) or rpc (thread/resume).

Approvals — app-server sends real approval requests (an rpc session has no PTY to defer to), so Arc owns the interaction: CodexDriverRegistry aggregates each driver's pending approvals; an inline card answers by echoing the server-offered decision back verbatim (the decision model — acceptForSession, acceptWithExecpolicyAmendment — is never collapsed to a boolean).

Renderer

  • Launch entry: a provider declaring both interactive and appServer surfaces two options (codex / codex · app-server); rpc launch/resume bypass the terminal-pane machinery.
  • Inline approval cards, live "generating" status, optimistic composer, and an app-server resume affordance on the detached overlay.
  • Focus split — "focus" was decomposed into terminalPaneId (PTY-pane focus) and activeTargetId (the current composer target, any runtime), so a paneless (rpc / future SDK) target can be the composer's addressee and the sidebar's active highlight.

Testing & review

  • 519 tests / 1 skipped (unit-covered: transport lifecycle, driver fold + approvals + resume, normalize, RpcSessionManager, router dispatch/detached/resume, shell focus semantics, approval view). Live end-to-end validated against the real codex binary throughout.
  • Reviewed by a Codex reviewer (driver correctness + the focus-split design) and the thermo-nuclear code-quality skill; the one structural finding (duplicated derived-field computation) was fixed by consolidating onto a single presentTargetSession.

Known edge / follow-ups

  • A Codex CLI that survives an app restart and fires a delayed SessionStart won't re-bind its native id (the restored session isn't in the PTY store); the id is already persisted and a normal resume re-adds it before hooks fire, so the common flow is unaffected.
  • Non-blocking follow-ups tracked: unify the two turn-lifecycle feeds, tag the submit result instead of "rows" in delivery, scope unify's per-tick read.

timhanlon added 21 commits July 4, 2026 13:34
Add a live-driver path for Codex built on `codex app-server` (JSON-RPC 2.0
over stdio) — the interface the Codex VS Code extension uses — as a third
acquisition transport beside the rollout-file scraper and the PTY TUI launch.
It lands in the same `ExtractedRows` + pending-input signal as the existing
provider, never a parallel pipeline; codex protocol types stay inside these
files.

Three composable layers, all verified:

- transport.ts — generic newline-delimited JSON-RPC client over a child
  process's stdio. Scoped spawn (killed on scope close); raw readline/exit/error
  callbacks only offer onto an inbound queue; one scoped fiber resolves
  id-correlated Deferreds and fans notifications / server-requests to Streams.
  On process death every pending request fails and both streams end.
- protocol.ts — Effect Schema for the item variants a turn produces
  (userMessage, agentMessage, reasoning, commandExecution), token usage, and
  the driver-facing thread/start + approval payloads. Mirrors codex.ts's
  Schema.Union + decode*Option idiom (unknown shape -> skipped, never throws).
- normalize.ts — folds a turn's item/completed payloads into the shared
  SessionRowBuilder / ExtractedRows. Structured commandExecution fields
  (command, exitCode, aggregatedOutput) drop the EXEC_WRAPPER telemetry-regex
  the rollout provider needs.
- driver.ts — handshake (initialize -> thread/start), notification fold, and
  the approval state machine: each item/*/requestApproval becomes an addressed
  PendingApproval in a SubscriptionRef (with server-supplied availableDecisions),
  cleared on serverRequest/resolved — the deterministic replacement for the
  racy outputText-flash inference.

Tests: hermetic node-peer coverage for the transport and the full driver flow
(handshake, approval surface+answer, turn->rows), plus an env-gated live smoke
(CODEX_LIVE=1) that drives the real codex binary end to end.
Add an optional `appServer` capability to ProviderSpec — a third launch mode
beside `batch` (non-interactive runs) and `interactive` (PTY TUI): a long-lived
JSON-RPC process on stdio the codex-appserver driver spawns and drives. Populate
it for codex (`codex app-server`).

Optional field, so the ListProviders RPC Schema stays backward-compatible. The
capability is additive — a provider that declares it can still be scraped
post-hoc and launched as a TUI. Its reader is the driver-launch wiring
(next step); this just makes the capability discoverable via ProviderRegistry.
Wire the driver into IngestStore so a live-driven codex session is
indistinguishable from a scraped one downstream (one store, one projection).

- driver: accumulate the thread cumulatively rather than per-turn. The store's
  replaceSession replaces a session with its whole row set (the file scraper
  re-parses the entire rollout each time), so each completed turn now yields the
  session's rows *so far*, not just that turn's delta.
- launch.ts: launchCodexAppServerSession reads a ProviderSpec app-server
  capability, spawns the driver, and persists each completed turn's cumulative
  rows via IngestStore.replaceSession — the reader of the appServer capability.
  pendingApprovals / answerApproval pass through unchanged.

Tests: hermetic node-peer coverage that a completed turn lands in an in-memory
IngestStore; the env-gated live smoke now drives two real turns through
launch -> driver -> normalizer -> store and asserts cumulative rows persist.
App-server approvals can't be answered by focusing a PTY (there is none), so
Arc must own the answer surface. First main-side pieces:

- PendingApproval / ApprovalRequestParams gain approvalId — codex's own approval
  handle (present on commandExecution approvals, absent on fileChange). It is
  display/correlation detail; the reliable routing key stays the always-present
  JSON-RPC request id the request arrived with.
- CodexDriverRegistry: owns the live drivers keyed by targetSessionId, mirrors
  each driver's pendingApprovals into one reactive aggregate (SessionApprovals),
  and routes AnswerApproval back to the right driver. register is scoped to the
  caller (the session launch): the mirror fiber + deregistration finalizer live
  in that scope, so a session that ends drops out of the aggregate.

Hermetic test registers a peer-backed driver, answers the approval *through the
registry* (proving per-session routing), and asserts the aggregate clears on
serverRequest/resolved.
The answer surface app-server needs, reaching the renderer:

- shared AppServerApproval schema — the inline card's view of an approval
  (requestId, approvalId, itemId, command, decisions). Ephemeral, never
  persisted. Each decision is {label, payload} where payload is the raw
  server decision re-encoded as JSON, so acceptWithExecpolicyAmendment and
  friends round-trip verbatim — the decision model is never collapsed to
  approve/deny.
- codex-approval-view: projectApprovals flattens the registry's per-session
  aggregate into that view; parseDecisionPayload decodes a chosen decision back
  to the raw value to answer the driver.
- three RPCs: ListAppServerApprovals (one-shot), WatchAppServerApprovals
  (stream), AnswerAppServerApproval (echo a decision payload back). Handlers
  read/route through CodexDriverRegistry, now provided in the runtime layer graph.

Pure test covers the projection + verbatim decision round-trip (string and
rule-carrying object decisions).
The renderer half of the pty-less answer surface. Unlike the Question card
(deliberately read-only — a PTY provider's picker owns the answer),
AppServerApproval *is* the answer surface: it renders the server-supplied
decisions verbatim as buttons and calls onAnswer(payload) with the raw decision
JSON, so acceptWithExecpolicyAmendment and friends round-trip unchanged.

Presentational only (approval + onAnswer + answering props), styled with the
shared Button/Badge primitives. Verified in Storybook across command-exec,
rule-carrying (execpolicy amendment), fileChange (no command), and answering
(disabled) states.
The structured-process sibling of the PTY TargetSessionManager, and the piece
that makes app-server sessions live. TargetSession identity is unchanged; only
the runtime owner differs (per the pty/rpc runtime split).

- launch(req): spawns the driver (persisting turns to the shared store via
  launchCodexAppServerSession) and registers its approvals with
  CodexDriverRegistry. Idempotent per targetSessionId. Each session gets a scope
  forked off the layer scope, so stop() closes just that session (kills the
  driver, deregisters its approvals) and app quit closes them all.
- submit(req): runs a user turn (driver.runTurn); accepted:false for an unknown
  session.
- stop / list round out the lifecycle.

Provider-agnostic (command/args come from the app-server capability, mapped in
by the caller); codex is the only RPC driver today, pi's rpc mode is the same
family. Wired into the runtime layer graph.

Hermetic test drives a node peer through the whole cycle: launch, answer an
approval *through the registry* (proving launch registered the driver), submit a
turn, assert it persisted to the shared IngestStore, then stop and assert both
the manager and the registry aggregate clear.
…urns

The one door onto both session runtimes, and the wiring that makes an app-server
session usable from the composer.

- SessionRuntimeRouter: launch dispatches by *intent* (LaunchRequest.runtime:
  "pty" | "rpc") — never by provider identity, since codex declares both
  interactive and appServer. submit/stop dispatch by *ownership* (which manager
  holds the id), so no persisted kind is needed. rpc-launch resolves cwd, creates
  + persists a TargetSession row, spawns the driver, and bindNatives the thread id
  (so the timeline projection can find the target). No migration.
- The router does NOT project rpc turns itself — that would depend on
  ChatMessageService, which depends back on the router. Instead submit returns the
  turn's rows and sendPrompt projects them via ingestArtifactSession, keeping the
  graph acyclic. RpcSessionManager.launch now returns the thread id; submit
  returns the cumulative rows.
- sendPrompt routes through the router: it skips the PTY attached-check for rpc
  sessions (no byte stream), submits via the router, and projects the returned
  rows into the chat timeline (the driver wrote IngestStore; the renderer reads
  the ArcStore projection).
- LaunchTarget / StopTarget RPCs route through the router; LaunchTarget gains a
  runtime field. SubmitPrompt stays PTY-raw.

Tests: router dispatch (submit/stop/ownsRpc by ownership, rpc turn carries rows,
unknown id falls through to pty). Full suite 506 passed / 1 skipped.
The pty-less answer surface: an app-server session has no terminal to defer
approvals to, so Arc owns the interaction.

- appServerApprovalsAtom (live WatchAppServerApprovals stream) +
  answerAppServerApprovalAtom mutation.
- ChatApprovals: chat-scoped container filtering the live approvals to the
  current chat, rendering AppServerApproval cards, echoing a decision's payload
  back verbatim, with optimistic per-request in-flight disabling. Mounted above
  the composer in UnifiedChatPane.
- Launch entry: LaunchableProvider carries runtime + label; a provider declaring
  both interactive and appServer surfaces two options (codex / codex .
  app-server). onLaunch branches — rpc fires LaunchTarget directly (no pane, no
  xterm measure), pty keeps the pane/measure path. No terminal pane for rpc
  falls out for free.
- ChatApprovals story (Pending filters an other-chat approval out; Empty renders
  null) — verified in Storybook.
A launched app-server session was persisted and resident in RpcSessionManager
but never appeared in the renderer: WatchSessions/ListSessions were backed only
by the PTY TargetSessionManager's in-memory store. The router also leaned on the
PTY manager's bindNative to record the thread id — a no-op for an rpc target
(WARN 'hook binding for unknown target'), so the DB row's nativeSessionId stayed
null and ingestArtifactSession could not resolve the target by (provider, native
id) to project a turn either.

- RpcSessionManager owns observable TargetSession state (SubscriptionRef), builds
  the session with nativeSessionId = threadId, and exposes sessions/changes;
  launch returns the full TargetSession.
- SessionRuntimeRouter drops bindNative, persists the DB row with the bound
  thread id, and exposes a unified view over both runtimes — Effect.zipWith for
  the list, Stream.zipLatestWith (rechunk-1 per side) for the change stream.
- ListSessions/WatchSessions handlers repointed at the router.

Tests: router asserts the rpc session surfaces in the unified list with its
thread id bound; RpcSessionManager asserts sessions populate/clear; rpc-streaming
swapped to a router stub.
…top lifecycle, launch cleanup

Three should-fix findings from a Codex review of the branch (work_01kwqe11np):

1. Transport hang after child exit. route({kind:"exit"}) failed pending requests
   but recorded no closed state, so a request made *after* the process died
   registered a Deferred that no future exit event could fail — an indefinite
   hang. Now the exit branch sets a closed error; request/notify/respond fail
   fast, with a register-then-recheck in request to close the interleaving
   window. Also close the readline interface in a scope finalizer.

2. rpc stop didn't mark the persisted row exited. The router now persists
   'exited' after a successful rpc.stop (best-effort + logged, mirroring the PTY
   manager's exit handler), so a restart's restorePersistedSessions can't
   resurrect a stopped app-server session as a stale non-attached PTY target.

3. launchRpc leaked the live driver if the DB upsert failed. The persist now
   pipes tapError -> rpc.stop(id), so a failed LaunchTarget doesn't leave a
   running driver + registered approvals + a phantom entry in the unified
   WatchSessions that the DB knows nothing about.

Tests: transport 'fails fast after child exit' (proves no hang); router 'marks
the persisted target row exited on stop'. Finding 3's cleanup is verified by
typecheck + inspection (a fault-injecting ArcStore stub isn't worth a one-line
tapError). +2 tests, suite 509 (508 pass / 1 skipped).
A codex session is transport-agnostic — the app-server thread id is a codex
session id, so the same session resumes over either transport. Until now resume
was PTY-only (codex resume <id> in a terminal); this adds resume back into the
app-server runtime, chosen as a resume-time intent (not a persisted session
property), mirroring the launch-by-intent design.

- Driver: makeCodexAppServerDriver takes an optional resumeThreadId and branches
  the handshake to thread/resume { threadId } vs thread/start. Both answer with
  { thread: { id } }, so the same decoder extracts the id and the session rejoins
  under its original native id.
- RpcSessionManager / launch: thread resumeThreadId through.
- SessionRuntimeRouter: resume(req) dispatches by req.runtime — rpc reads the
  persisted row, rpc.launch({ resumeThreadId: nativeSessionId, ... }), and flips
  the durable row back to running. ResumeTarget now routes through the router;
  ResumeRequest / the ResumeTarget RPC gain a runtime intent.
- Renderer: the detached-session overlay offers 'resume <provider> · app-server'
  when the provider declares an appServer capability; it fires ResumeTarget with
  runtime:rpc directly (no pane). The resumed session comes back attached, which
  auto-clears the detached overlay (it keys on !attached) and surfaces it in the
  chat pane.

Tests: driver rejoins by id via thread/resume; router rpc-resume rejoins under
the same native id + re-marks the row running (provider registry stubbed to a
scripted peer so the path is drivable without the real codex binary). +2 tests,
suite 511 (510 pass / 1 skipped).
Resuming a codex session into the rpc runtime showed it twice ('codex 1
detached' ×2) and never cleared the detached overlay. Every persisted row is
boot-restored into the PTY TargetSessionManager as a detached shell (it can't
tell rpc from pty — no runtime column). rpc-resume then made the same id live in
RpcSessionManager too, so one identity was held by both managers and the unified
list (pty ++ rpc) carried it twice. Worse, detachedSession keys on
'!attached' (shellSelectors), so it kept finding the stale PTY copy and the
overlay stuck.

The fix is ownership transfer, not view dedup: a single id is owned by at most
one manager. TargetSessionManager gains release(id) — drops an id it holds only
as a boot-restored detached shell (refuses if a live PTY child exists, which
would orphan a process). The router calls pty.release(row.id) after the rpc
session is live, so the id leaves the PTY store. The merge stays a plain concat;
no precedence rule needed.

Test: the router resume test now runs against a PTY stub that holds the id as a
detached shell and honors release, and asserts the unified list carries it
exactly once (attached) afterward — it'd be two without the release.
…turns

Two UX gaps for app-server (rpc) chat turns:

1. The composer held the sent text (greyed, disabled) until the turn finished,
   because SendChatPrompt awaited the whole rpc turn. The composer is now
   optimistic: it clears the draft immediately and lets the turn run in the
   background (the reply lands via the chat-changes stream), matching how a PTY
   submit returns instantly. Restores the prompt if the send errors.

2. No 'codex generating' indicator. LiveTargetStateService only iterated the PTY
   TargetSessionManager, so rpc sessions (which live under RpcSessionManager) had
   no live state at all, and had no turn signal even if listed. It now reads the
   unified SessionRuntimeRouter session list and unions in a new
   RpcSessionManager.generating marker (an rpc session has no hook stream, so the
   manager sets the marker around each turn) — so the composer/sidebar paint
   'generating' the same way they do for a PTY turn's hooks.

RpcSessionManager also serializes turns per session with a semaphore: the freed
composer makes concurrent submits possible, and the driver folds one turn at a
time (unkeyed outcomes) — closing the concurrency hazard the review flagged.

Test: manager marks the session generating while a turn is in flight (observed
mid-turn while it blocks on an approval) and clears it after. +1 test, suite 512
(511 pass / 1 skipped).
The composer's optimistic user row was persisted immediately, but the chat-update
publish only fired at the end of sendPrompt. An rpc turn holds that handler open
for the whole turn, so the user's own message didn't surface until codex replied.
Publish right after the optimistic upsert so the bubble shows at once; the
end-of-send publish still delivers the reply, and reconcileComposerOptimisticUser
dedups the row when the turn projects.
… rpc

An rpc (app-server) target has no PTY, so focusing/adopting one used to mount an
empty xterm (a stray cursor). Add a live `runtime: "pty" | "rpc"` field to
TargetSession (set by RpcSessionManager; not persisted — it reflects which
manager currently owns the session), thread it into the shell's ShellSessionRef,
and use it to keep rpc sessions out of the terminal machinery: adoption skips
them, and SESSION_FOCUSED gives them a chat-only focus with no pane.

Foundation for the focus split (work_01kwqktppt): the composer-target vs
PTY-pane-focus decoupling lands next and replaces the chat-only SESSION_FOCUSED
branch here (which currently can't steer the composer to an rpc target when a
chat holds multiple targets).
Session focus conflated two notions that only coincide for PTY targets: the
"active session" was derived from the active terminal pane, and drove both the
composer addressee and the sidebar highlight. A paneless target (rpc app-server;
Cursor SDK and other non-PTY runtimes next) could never become current — so
focusing the app-server target chat-only (the cursor fix) left the composer
still addressing a PTY target when a chat held multiple.

Split them into two first-class notions:
- terminalPaneId (unchanged) — the focused PTY pane (xterm keyboard/surface).
- activeTargetId (new, on ArcShellContext, not persisted) — the current composer
  target, any runtime.

Focus always sets activeTargetId; a PTY focus/bind also activates its pane, a
non-PTY focus sets only activeTargetId (no pane, terminal untouched). It's set
everywhere a target becomes current — SESSION_FOCUSED, TARGET_BOUND, and the
direct rpc launch/resume path (new focusTarget action, focuses by the returned
session ref so it doesn't race the live list). On PTY_EXITED it follows to the
newly-active pane's session only if the exiting target was active; chat switch
clears it. The selector resolves it against the live sessions (attached +
in-chat) so a stale id never leaks, and the addressableTarget first-attached
fallback is demoted to a cold-start-only default. Design + Codex review on
work_01kwqktppt.

+6 shell-machine tests (pty focus sets both; rpc focus sets only the target +
no terminal; target follows last focus across runtimes; TARGET_BOUND sets it;
PTY exit clears only when the exited target was active; chat switch clears;
detached never becomes composer target). Suite 519 (518 pass / 1 skipped).
…c-resume ownership transfer)

A boot-restored session isn't live in any runtime — it's just a persisted row.
But boot-restore seeded EVERY persisted row into the PTY manager's live store as
a detached shell (it can't tell rpc from pty), so a former rpc session came back
masquerading as a PTY session. Resuming it as rpc then had to wrench it out of
the PTY manager (pty.release) into the rpc manager — an 'ownership transfer' with
a one-tick window where both managers held the id, which the id-keyed composer/
sidebar chips reconciled into a ghost row ('codex' + 'codex 1').

Model it directly instead. The PTY manager's store holds only sessions with a
live PTY; boot-restore just re-arms hook sockets (rearmPersistedSessionHooks) and
no longer seeds the store. The router's unified list is now
  live PTY ∪ live rpc ∪ (persisted rows not live and not exited)
— the last set is 'detached', read from the DB and runtime-neutral. An id is
either live in one manager or detached, never both: no duplicate, no ghost, no
handoff. Resuming a detached session launches it into a runtime; it becomes live
and drops out of the detached set because its id is now live. rpc stop marks the
row exited before removing it from the store so the detached recompute (driven by
that store tick) doesn't resurrect it. PTY resume reads the row from the DB
(restoredSessionFromRow) since the store no longer holds detached sessions.

Removes TargetSessionManager.release and SessionRuntimeRouter.resumeRpc's
pty.release. Supersedes the release band-aid (99656fe). Design + review on
work_01kwqpx98s.

Known edge: a codex CLI that survives an app restart and fires a *delayed*
SessionStart won't re-bind its native id (the restored session isn't in the PTY
store) — the id is already persisted, and a normal resume re-adds the session to
the store before its hooks fire, so the common flow is unaffected.

Tests: router surfaces a persisted not-live not-exited row as detached (and
excludes exited); rpc resume rejoins with no duplicate; boot-restore split into
restoredSessionFromRow (pure) + rearmPersistedSessionHooks. Suite 520 (519 pass /
1 skipped).
The detached-set refactor excluded exited rows, so a stopped rpc session vanished
from the sidebar entirely — and its transcript messages lost their source label
(the renderer resolves a message's target label from the live session list, which
no longer held the session). That was a regression from prior behavior, where a
PTY exit stays in its store as exited and every persisted row surfaced.

Include every persisted row not currently live, carrying its state — exited rows
show as exited (stopped sessions stay visible + their messages resolve their
source), everything else as unknown/detached. rpc stop still marks the row exited
before removing it from the store, so it shows as exited rather than a spurious
resumable-looking 'unknown' for a tick.

Test updated: the unified list keeps an exited row (as exited) rather than
dropping it.
The detached overlay reads TargetSession.resumable to show the resume affordance.
The PTY manager's list sets it (resumable: canResume(s)), but the router's
DB-derived detached set didn't — so a restored/stopped codex session showed
'not resumable' even though it has a native session id. Apply canResume in the
router's unify when projecting a persisted row into a detached session.
The renderer-facing derived fields (attached, resumable) were computed
independently in TargetSessionManager.asList and the router's detached-set
projection, so the two paths drifted on which fields they set — a divergence that
shipped as three separate bugfixes on this branch (missing resumable, dropped
exited rows, lost source label). Consolidate onto presentTargetSession(s,
attached) in provider-args, called by both asList and the router's unify. A field
added in one is now structurally impossible to miss in the other.

Structural fix from the thermo-nuclear review (work_01kwqs3bmq).
@greptile-apps

greptile-apps Bot commented Jul 5, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds OpenAI Codex's app-server JSON-RPC transport as a third live session runtime alongside the existing PTY TUI and rollout-file scraper. It introduces three quarantined layers (transport, driver, launch), two new services (RpcSessionManager, SessionRuntimeRouter), inline approval cards, and a "focus split" that decouples the terminal-pane focus from the composer's active target so paneless RPC sessions can be addressed.

  • New transport stack (codex-appserver/): a generic ndjson-RPC transport over a child process, a Codex-specific driver that folds item/* / turn/* notifications and runs the approval state machine, and a launch wrapper that persists turns to the shared IngestStore.
  • Session routing refactor: SessionRuntimeRouter is the new single entry point for LaunchTarget, ResumeTarget, StopTarget, and SendChatPrompt; it dispatches by launch intent (pty | rpc) and by ownership for submit/stop. RpcSessionManager mirrors TargetSessionManager for the RPC runtime and serializes launches + turns with per-session semaphores.
  • Renderer additions: ChatApprovals renders inline answer cards for pending app-server approvals, and the shell machine gains an activeTargetId field decoupled from terminalPaneId so RPC sessions can be the composer's addressee without mounting a terminal pane.

Confidence Score: 4/5

The change is safe to merge; the one concrete bug is confined to the approval UI and only activates when two app-server sessions in the same chat both have a pending approval at the same time.

The approval-answering key in ChatApprovals.tsx uses only the JSON-RPC requestId without the targetSessionId. Because each session's transport numbers its requests from 1, two concurrent sessions in the same chat will inevitably share requestId values. When this happens, answering one session's approval also disables the other session's approval button, leaving the user unable to click it. All other code paths — the transport lifecycle, turn serialization, launch-lock idempotency, ghost-message rollback, and the shell focus split — appear correct.

src/renderer/src/chat/ChatApprovals.tsx — the approval key and answering-state key both need to incorporate targetSessionId.

Important Files Changed

Filename Overview
src/renderer/src/chat/ChatApprovals.tsx New inline approval card container; the answering-state key and React key both use only requestId without targetSessionId, causing collisions when two sessions in the same chat have pending approvals with the same sequential requestId.
src/main/ingest/providers/codex-appserver/transport.ts New generic ndjson JSON-RPC transport over a child process; scope finalizer correctly fails all pending deferreds on both intentional stop and process crash.
src/main/ingest/providers/codex-appserver/driver.ts Codex app-server protocol driver; folds item/turn notifications and runs the approval state machine; aborted-signal path ensures runTurn doesn't hang on process exit.
src/main/services/RpcSessionManager.ts Runtime owner for RPC-backed sessions; serializes launches via launchLock semaphore and turns via per-session semaphore; mirrors TargetSessionManager patterns.
src/main/services/SessionRuntimeRouter.ts Unified dispatch layer for both session runtimes; routes by launch intent and submit/stop ownership; DB read in unify on every session-change tick is a known follow-up.
src/main/services/ChatMessageService.ts Adds rpc submit path; optimistic publish + rollback publish; duck-typed 'rows' in delivery dispatch flagged as known follow-up.
src/renderer/src/shell/arcShellMachine.ts Adds activeTargetId decoupled from terminalPaneId; rpc session focus skips terminal pane mounting; pane-close correctly follows activeTargetId only when it was the exiting target.
src/renderer/src/App.tsx Adds rpc launch/resume paths that bypass the shell pane machinery; focusRpcTarget sets activeTargetId by ref to avoid race with session list update.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant R as Renderer
    participant Router as SessionRuntimeRouter
    participant RpcMgr as RpcSessionManager
    participant Driver as CodexAppServerDriver
    participant Transport as AppServerTransport
    participant Codex as codex app-server

    R->>Router: "LaunchTarget {runtime:"rpc"}"
    Router->>RpcMgr: launch(req)
    RpcMgr->>Driver: makeCodexAppServerDriver()
    Driver->>Transport: makeAppServerTransport()
    Transport->>Codex: spawn()
    Driver->>Codex: initialize → initialized → thread/start
    Codex-->>Driver: "{thread:{id}}"
    RpcMgr-->>Router: "TargetSession {nativeSessionId}"
    Router->>DB: upsertTargetSession
    Router-->>R: TargetSession

    R->>Router: "SendChatPrompt {text}"
    Router->>RpcMgr: "submit {text}"
    RpcMgr->>Driver: runTurn(text)
    Driver->>Codex: turn/start
    Codex-->>Driver: item/completed notifications
    Codex-->>Driver: requestApproval (server to client)
    Driver->>R: pendingApprovals update via CodexDriverRegistry
    R->>Router: "AnswerAppServerApproval {decision}"
    Router->>Driver: answerApproval(id, decision)
    Driver->>Codex: "respond {decision}"
    Codex-->>Driver: turn/completed
    Driver-->>RpcMgr: "TurnResult {rows}"
    RpcMgr-->>Router: "{accepted:true, rows}"
    Router-->>R: rows projected into chat timeline
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant R as Renderer
    participant Router as SessionRuntimeRouter
    participant RpcMgr as RpcSessionManager
    participant Driver as CodexAppServerDriver
    participant Transport as AppServerTransport
    participant Codex as codex app-server

    R->>Router: "LaunchTarget {runtime:"rpc"}"
    Router->>RpcMgr: launch(req)
    RpcMgr->>Driver: makeCodexAppServerDriver()
    Driver->>Transport: makeAppServerTransport()
    Transport->>Codex: spawn()
    Driver->>Codex: initialize → initialized → thread/start
    Codex-->>Driver: "{thread:{id}}"
    RpcMgr-->>Router: "TargetSession {nativeSessionId}"
    Router->>DB: upsertTargetSession
    Router-->>R: TargetSession

    R->>Router: "SendChatPrompt {text}"
    Router->>RpcMgr: "submit {text}"
    RpcMgr->>Driver: runTurn(text)
    Driver->>Codex: turn/start
    Codex-->>Driver: item/completed notifications
    Codex-->>Driver: requestApproval (server to client)
    Driver->>R: pendingApprovals update via CodexDriverRegistry
    R->>Router: "AnswerAppServerApproval {decision}"
    Router->>Driver: answerApproval(id, decision)
    Driver->>Codex: "respond {decision}"
    Codex-->>Driver: turn/completed
    Driver-->>RpcMgr: "TurnResult {rows}"
    RpcMgr-->>Router: "{accepted:true, rows}"
    Router-->>R: rows projected into chat timeline
Loading

Reviews (6): Last reviewed commit: "fix(codex): serialize rpc launches (atom..." | Re-trigger Greptile

Comment thread src/main/ingest/providers/codex-appserver/normalize.ts
Comment thread src/main/ingest/providers/codex-appserver/driver.ts
Comment thread src/main/services/ChatMessageService.ts
Comment thread src/main/services/SessionRuntimeRouter.ts
…solved id)

Two P2 findings from the PR review:
- normalize: agentMessage stored the untrimmed item.text while guarding on the
  trimmed value — inconsistent with userMessage. Store the trimmed text.
- driver: serverRequest/resolved extracted requestId as unknown; an absent field
  left it undefined, and approval.id !== undefined matches every pending
  approval, so the filter cleared nothing and the card stuck until scope close.
  Guard on a real number|string id.

The other two findings ('rows' in delivery duck-typing; unify's per-tick DB read)
are the already-tracked non-blocking observations on work_01kwqs3bmq.
Comment thread src/main/services/ChatMessageService.ts
…bble)

Greptile P1: the optimistic echo publishes immediately to show the user's bubble.
When the rpc turn then fails (CodexDriverError -> accepted:false), the rollback
deletes the row but didn't publish, so the renderer kept showing a bubble whose
DB row was gone until the next message in the chat forced a refetch. Publish the
updates after the rollback delete so the ghost clears.
Comment thread src/main/ingest/providers/codex-appserver/driver.ts
Greptile P1: runTurn does turn/start then Queue.take(turnOutcomes). If the
process crashes after the turn/start ack but before turn/completed, the transport
shuts its notification queue, the fold fiber ends, but turnOutcomes never gets a
completion and is never closed — so the take blocks forever, hanging the
SendChatPrompt handler.

The fold now offers an 'aborted' signal (a discriminated TurnSignal) when it
finishes, via ensuring (not andThen: the queue shutdown *interrupts* the fold, so
andThen would be skipped). runTurn takes the signal and fails with a
CodexDriverError on abort instead of blocking. Test: driver fails (doesn't hang)
when the peer acks turn/start then exits before turn/completed.
Comment thread src/main/ingest/providers/codex-appserver/transport.ts
…n stop)

Greptile P1 (symmetric to the mid-turn fix): pending request Deferreds were only
failed in route's exit branch, which needs the drain fiber alive. On an
intentional stop, closing the session scope interrupts the drain fiber *before*
the finalizers kill the child, so the exit is never routed and a request blocked
on Deferred.await (e.g. turn/start) hangs forever, holding SendChatPrompt open.

Extract failAllPending() and also run it as a scope finalizer, so pending
requests fail on scope close regardless of the drain fiber. Idempotent with the
exit route. Test: an in-flight request fails (doesn't hang) when the transport
scope is closed while it's pending.
Comment thread src/main/services/RpcSessionManager.ts Outdated
Greptile P1: RpcSessionManager.launch had no lock, so two concurrent launches of
the same target id (a double-clicked resume) both passed the idempotency guard,
both spawned a driver + thread/resume, and the second sessions.set orphaned the
first scope — a leaked codex process and two clients on one thread. Wrap launch
in a per-manager Semaphore.make(1), mirroring the PTY manager's launchLock, so
the check + spawn + set are atomic and the second launch returns the first's
session.

Swept the rest of the rpc lifecycle for the same families while here: both
teardown-hang points (transport Deferred.await, driver Queue.take) are already
guarded; stop is idempotent (Scope.close is safe to repeat). Test: concurrent
launches of one id spawn a single driver (peer reports its pid as the thread id).
@timhanlon
timhanlon merged commit 5c8ef10 into main Jul 5, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant