✨ Launch Codex sessions with <Session.Launch> (#755) - #764
Conversation
| codex: { | ||
| launcher: "codex", | ||
| // Codex creates the conversation through ACP and reports what it is called. | ||
| // XMD supplies nothing here and accepts only that assertion. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // XMD supplies nothing here and accepts only that assertion. |
| // | ||
| // The first thing the Codex ACP adapter consults when deciding which | ||
| // Codex to run, so the build that creates the session through ACP is the | ||
| // build the native UI then resumes it with. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // build the native UI then resumes it with. |
| // resume <id>` reads rollouts — so a thread ACP created and nothing has | ||
| // spoken in is refused by name. One turn closes exactly that gap and | ||
| // nothing else, which is why the prompt asks for an acknowledgement and | ||
| // forbids the work the session was prepared for. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // forbids the work the session was prepared for. |
| // child of a build nothing has observed, which is the one thing a bound | ||
| // session may not talk to. Nothing on that path needs the answer: a | ||
| // client-native session is created by a native process, and where ACP does | ||
| // serve one, the establishment itself reports being unable to. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // serve one, the establishment itself reports being unable to. |
| if (route.schema !== "session-route.v3") { | ||
| // Constructed before any build was recorded. A build observed now says | ||
| // which build is installed today, not which one issued this identity, so | ||
| // there is nothing to compare and nothing to continue. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // there is nothing to compare and nothing to continue. |
| } | ||
| const agentCommand = agentCommandOf(prepared); | ||
| // An existing managed entry, or a durable record ACPX already kept, is | ||
| // provider state — and existing history is never reclassified. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // provider state — and existing history is never reclassified. |
| // established placement has one of its own. | ||
| // established placement has one of its own. A build alone does not | ||
| // defer anything — a session the provider names is still constructed | ||
| // by this ensure, bound to the build that names it. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // by this ensure, bound to the build that names it. |
PR #764: ✨ Launch Codex sessions with <Session.Launch> (#755)90 files, +20491 / -3874 Scope🔴 PR has 24365 lines changed. Split into focused PRs. 🟡 24365 lines changed. PRs under 400 receive more thorough review. 🟡 90 files changed. Are all changes related? 🟡 Changes span 14 directories. 🟡 PR mixes config and source changes. Structural🟡 Type declarations with no consumers: LaunchPhase, ExecutableRefusal.
Oxlint structural signals:
Slop
Oxlint slop signals:
Static AnalysisOxlint: 124 diagnostics across 23 files (26 rules) consistent-function-scoping (16): packages/acp/tests/fixtures/codex-native-launch-proof.ts, packages/terminal-tmux/src/pane-worker.ts, packages/test-agent/src/provider.ts (+1) CorrectnessNo extraneous code patterns detected. |
An advertised adapter name selected a command shape and, by omission, authorized acting on a session a native UI may be in. DEC-017 makes the authority a point — adapter, capability, exact reported version, host OS and architecture — so one real-CLI proof admits exactly what it proved. The host states the machine as a value beside the coordinator, route store and observer, so shared provider and session assembly never detects the runtime it happens to be on.
A native launch acknowledged ownership quiescence whenever `stop()` on its scope succeeded. A launch that completed naturally tore itself down first, so a finalizer that failed on the way reached the owner as a crash while `stop()` afterwards succeeded against an already-settled scope — proof that the scope is settled now, never that the cleanup which failed had succeeded. The session was released idle with an unproved child behind it. The invocation now suspends once the native child settles, so the one teardown there is happens under the one call that reports it. Quiescence is still withheld unless the child settled, every finalizer of that invocation completed, and the provider holds no handle; otherwise the exclusion is released with the record left active, and the next acquisition is refused as session-recovery-required before any spawn, ensure, allocation or route work. The original cleanup failure is what the launch caller observes.
An exact-version compatibility point admitted a capability from the release string an executable happened to print. That is the wrong evidence twice: a release name says nothing about which operations a build declares, and pinning one refuses every later build that declares the same shape. Admission is now four exact facts — the adapter's own versioned protocol, the capability being asked for, the profile its observer returned, and the host's platform and architecture. An Agent registry name or a launcher command admits nothing. The two Claude capabilities are read independently, so neither is ever inferred from the other having been proved. The host-owned observer is generalized: it resolves and canonicalizes the launcher, requires an executable regular file, hashes it once, and asks that same exact path whatever read-only queries the adapter declared, returning settled status and captured output. It knows no provider and parses nothing. Reading Claude's `--help` structurally — caller-supplied identity, exact resume, private-file instructions — stays in the adapter, so additive options, prose and wrapping do not change the answer. Version becomes optional evidence beside the digest rather than a gate. A build that will not name its release, names it in words this adapter does not recognize, or names several is bound by its bytes alone. Build equality is asymmetric: the digest decides, a retained release is a claim the live build must still make, and a record that named none is not rewritten when one appears.
The help probe read flag spellings. `--session-id <name>`, `--resume <url>` and `--system-prompt-file <text>` all satisfied it, so a build that accepts the spelling and means something else by it was admitted for native launch — and the launch would then hand a UUID to an option that takes a name. Each declaration is now parsed into its spellings, its value placeholder, whether that value is required, and its own description. Chosen identity needs the sole `--session-id` to require a UUID. Exact resume needs the sole `--resume` to name session identity, either in its placeholder or, for the uncommitted `[value]` spelling Claude ships today, in that entry's own words. Private instructions need a file-valued `--system-prompt-file`, or the `--system-prompt[-file]` family spelling inside a parsed entry. Product is read from dedicated unindented lines, so a compatibility claim or a quoted usage example no longer says what this executable is. Two entries declaring one spelling is unreadable, not a choice between them.
Three surfaces that say the opposite of the contract were admitted by it. `Resume a conversation by URL; session ID is not supported` contained both words the read looked for. `Inline text; file paths are not supported` was accepted from a description while its argument was `<text>`. `Claude Code compatibility wrapper` began a line with the product name. Prose is now read one clause at a time, and a clause that withdraws what it states admits nothing: a sentence states one thing, and its neighbours are not it. A generic `[value]` resume needs the phrase saying the argument is what the conversation is named by, not two words scattered through it. The private instruction file is decided by the argument alone, since a description explains a value rather than changing it. A product line names its subject and then describes it, so words running on into a longer name have named something else. The 2.1.241 and 2.1.263 surfaces, additive options, wrapping and unrelated prose are unchanged, and no version is consulted.
A route's `ExecutableBuildBindingV1` was a release lock: every continuation held the live observation against the retained one, so upgrading Claude stranded every session the previous build had opened. It is now immutable audit evidence — written once at first publication, never rewritten, and never compared with a live build. What authorizes acting on an existing session is what the installed executable independently proves: the route's stable adapter protocol, the capability the work needs, the shape its own probe recognizes, and this host. Which build accepted the identity first is history, and history is not a capability. Removes the four live-vs-retained comparisons (attachment, native resume, concurrent winner, replay) and the `buildDrift` helper they shared. The one remaining comparison stays: a route and its prepared journal are two durable accounts of a single observation, so a difference between them is still a replay that cannot say which session it is resuming. Tier XR takes a 2.1.261 session to a 2.1.263 build across native resume, ACP attachment, prepared-only replay, detached replay, a concurrently published winner, and a build reporting no release — each asserting the exact retained identity, unchanged audit evidence, no allocation or republication, and the live executable's own path. XR7 and XR8 hold the fail-closed boundary that a release change is not a protocol change.
A V2 construction route fixes the stable protocol its identity was published under through its provider, agent and launcher contract. Continuation read that protocol off whatever adapter happened to be registered under the launcher instead, so an adapter declaring another protocol — beside a host policy that had proved exactly that protocol, for that capability, in that shape, on this machine — could adopt a session it never constructed. ACP attachment also never held the route's retained launcher to the live adapter at all. The pin is compiled in and reachable through no host-supplied dependency, and it is asked before the executable is observed: which conversation this is was never a question about a build. Native resume, ACP attachment, concurrent-winner adoption and both incomplete replay phases now require the live adapter's launcher and protocol to be the ones the retained contract names, and the independent admission of the current executable's capability, shape and host follows it. A refusal observes nothing, allocates nothing, launches, ensures, prompts and constructs nothing, and leaves the route and the journal exactly as their first publication left them. Executable bindings stay what they became: immutable audit evidence, and a cross-check between two durable accounts of one observation — never a live same-build gate. The source contracts that still described them as one are reconciled. Evidence: XR9 takes an injected adapter speaking another protocol, with the host policy admitting that protocol, through native continuation, ACP attachment, both replay phases and concurrent-winner adoption, and asserts the executable is never even observed. XR10 refuses an attachment whose retained launcher is not the live adapter's. Planting each seam's absence back one at a time fails only that seam's case, and reading the pin off the adapter fails both cases and nothing else.
An empty route read settles nothing: a concurrent publication is only revealed by publishing. Until now a run whose read found no route fell straight through to observing the executable, allocating an identity and writing a candidate, and asked whether it could account for the protocol only after meeting the winner — having already acted on behalf of a session it was never entitled to construct. The pin is now asked twice from one reading. Prospectively, before any observation or allocation, against the live adapter's launcher; and again of the record that actually won, whose route may differ from the candidate's. The legacy V1 path is untouched. Reconcile the remaining same-build continuation wording across the touched source set. A retained binding is immutable audit evidence and is only ever held to the prepared journal derived from the same observation, never to a build installed later.
`packages/terminal` is created by this stack's extraction commit, which was authored while the repository pinned `@effectionx/node` 0.2.4. Main has since moved to 0.2.5 (#748). A file a commit creates conflicts with nothing, so the rebase carried the stale pin forward beside a root that now asks for 0.2.5, and `deno task setup` refused: the frozen lockfile carries no `npm:@effectionx/node@0.2.4` specifier for a workspace member to resolve through. The pin now matches the one every other package and the root already use. The 0.2.4 entries that remain in `deno.lock` are `@effectionx/process@0.8.1`'s own transitive dependency, and they are present on main unchanged. No other dependency version moves.
Three call sites this stack added kept compiling against surfaces main has since changed. Each pair of edits touched different lines, so the rebase merged both sides without a conflict and the mismatch only surfaced under `deno check`. `xmd plan` used to settle a whole agent stack through a local three-argument helper. Main replaced that with the authorship-only resolver, which takes two arguments because the command starts no program, so the terminal-grid installer this stack threaded through no longer has a parameter to occupy. `xmd run` is unchanged and still installs it: the installer reaches `installRunAgentStack` through `resolveAgentStack`, which is the one path with a terminal to give away. The syntax suite's two terminal-grid rows still named `syntaxCatalog` and the local `parseCatalog`, which main renamed to `syntaxSymbols` and `parseSymbols`. Only the names move; both rows assert the same origins, authored forms, descriptions and reserved-name exclusions they always did. The launcher suite imported `reap` from the provider-neutral contract after this stack split the module in two. `reap` ends a POSIX child, so it comes from `posix-launcher.ts` beside `installForegroundLauncher`, and the contract import keeps only what a consumer that never reaches a process needs.
The rebase reintroduced fourteen listeners the ownership rule refuses: a raw `once()` in the POSIX launcher's spawned callback, subscriptions whose `ensure()` was yielded after them — itself a suspension an owner can be halted in — removals hidden behind a helper the pairing cannot be read from, a self-removing socket handler a cancelled scope never reaches, and three process signals registered under a computed name. Every one of them now names its receiver, event and handler on both sides, with the removal established before the subscription or held by a `finally` around it. The launcher keeps its inline exit/error race, its complete reap and a spawned callback that fires exactly once: the handler comes off when the start arrives and again on every way out, so a cancelled or failed launch publishes no readiness at all. `usePaneChannels` remembers each accepted socket with the handler it carries and detaches every one of them at teardown, including a socket that closed on its own. FL10 reads the child's own listener counts after delivery, after a startup failure and after cancellation, then replays `spawn` on it: restore the raw `once()` and the row fails. TW14 already held the tmux half, and fails if the new channel detach is removed. Terminal-grid TG3 expects the catalog version main's Syntax v2 migration publishes. Co-Authored-By: Claude <noreply@anthropic.com>
24551e8 to
44826a9
Compare
| useMachineSessions(), | ||
| // The machine, read at the one place that is actually running on it. A | ||
| // native capability is admitted for an exact OS and architecture, so | ||
| // those cross from here as values rather than being detected below. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // those cross from here as values rather than being detected below. |
| useMachineSessions(), | ||
| // The machine, read at the one place that is actually running on it. A | ||
| // native capability is admitted for an exact OS and architecture, so | ||
| // those cross from here as values rather than being detected below. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // those cross from here as values rather than being detected below. |
| if (typeof code !== "number") { | ||
| // A child that produced no status did not answer, whatever it wrote on the | ||
| // way. Reporting output beside an unknown status would invite reading it as | ||
| // an answer. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // an answer. |
| child.on("exit", onExit); | ||
| // Established before the subscriptions and naming every one of them: | ||
| // entering an ensure() is itself a suspension, so a scope halted while it | ||
| // registers unwinds with nothing on it at all. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // registers unwinds with nothing on it at all. |
| live.add(socket); | ||
| closable++; | ||
| const onSocketClose = (): void => { | ||
| // A socket that closed is nobody's to close again. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // A socket that closed is nobody's to close again. |
| // child inherits this process's, so a pane collapsing it to `{}` started | ||
| // the program with no environment whatsoever — no `TERM`, so no colour, and | ||
| // no `PATH` or `HOME` either. An environment that *is* supplied crosses | ||
| // exactly, gaining nothing ambient. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // exactly, gaining nothing ambient. |
| // tmux sends its first record immediately on attach, so a handler | ||
| // attached after `exec()` returns is attached after that record could | ||
| // already have been forwarded. Nothing about the parsing below changes — | ||
| // the stream is still read and classified exactly as before. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // the stream is still read and classified exactly as before. |
| } | ||
| // The default shell starts: a suite that says nothing about a pane | ||
| // wants a pane that works, and one that never reported a spawn would | ||
| // hang the readiness barrier instead. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // hang the readiness barrier instead. |
| } finally { | ||
| // Counted down however the shell left — returned, thrown, or | ||
| // cancelled — because a shell a suite can still find is a shell the | ||
| // provider is still holding. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // provider is still holding. |
| started.once("spawn", () => spawned()); | ||
| // reports having started. Off on arrival and off again in the `finally`, | ||
| // so one start is reported exactly once and a launch that was cancelled or | ||
| // never started reports none at all. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // never started reports none at all. |
Include the extracted terminal package in SYN47's explicit published payload closure and assert its lifecycle subpath resolves to the staged file. Production manifests already declare the dependency; preserve canonical workspace imports and the no-reexport package boundary.
Normalize Linux ps's no-terminal spelling to the existing process-interface marker without changing real device names or holder proofs. Declare the extracted terminal packages only in the private root test host, keeping provider dependencies out of the neutral domain. Freeze both boundaries with regressions; regenerate the two pnpm workspace links through setup.
Run the native-grid TestAgent harness through a focused subprocess entry that installs the compiler and invokes the real worker. Keep ACPX, controller transport, scenarios, journal, lifecycle, public CLI dispatch coverage, and the 20-second document deadlines intact. Require the GN2 journey to complete successfully as well as checking retained identity.
Why
<Session.Launch>knew Codex'scodex resume <id>command shape but refused to use it because XMD had not yet proved that ACP's provider-returned identity names the exact conversation the native Codex UI resumes.This PR completes that proof and makes Codex available beside Claude at the root and inside a terminal-grid pane.
Closes #755. #770 is independent: it tracks Claude's provider-origin resume identity and does not block Codex's provider-native continuation.
What changes
Before:
<Agent name="codex"><Session.Launch>…</Session.Launch></Agent>refused because Codex was not advertised for native launch.After:
_meta.agentSessionIdassertion.codex-materialization.v1turn. The authored instructions are not in that turn; they govern the first native user turn.codex resume <canonical-id>opens the exact prepared conversation. Resume and replay never spend the materialization turn again.<Terminal>panes. Notifications, readiness, input, output, and teardown stay in the selected pane.<Session>or<Prompt>performs a zero-turn live resume/load check and refuses before returning or prompting if the provider omits or changes the canonical identity.How it works
The V3 route is exactly
{schema, route, provider, agent, sessionKey, executableBinding}. The route and launch journal retain two accounts of the original observation and must agree; neither is compared to a later executable as an authorization lock.The materialization response and usage are retained only as filtered lifecycle evidence. They are not rendered as document output, do not contain the authored task, and cannot use tools or permissions. Unsafe or incomplete materialization refuses before the native process starts.
Provider-native continuation carries the retained canonical identity into the actual ACP resume/load. The live returned identity is checked before reconciliation, checkpointing, Session return, establishment publication, or prompt submission. Missing or different identity preserves the original conversation and refuses
identity-unavailable.Review guide
Start with:
specs/decisions.mdDEC-018 andpackages/acp/src/native-launch.ts.Then review:
packages/acp/src/session-route.tsandpackages/acp/src/provider.ts— V3 publication, materialization, continuation, replay, and ownership.packages/core/src/agent/launch-*.ts— the retainedmaterializedphase and reconstruction from a completed prompt.packages/terminal/src/native-launcher.tsandpane-launcher.ts— provider-neutral notification routing.packages/acp/tests/native-reconnect.test.ts— real ACPX resume/load identity enforcement for Prompt and eager Session.packages/test-agent/tests/terminal-grid-native-launch.test.ts— TG22 mixed Claude/Codex pane behavior.Look carefully at:
sessionId, request echo, route key, transcript, or UUID shape;--last, replacement conversation, widened cwd, or widened permissions appears;What must stay true
How to verify it
Focused implementation evidence at Architect-reviewed signed commit
fffc286b0b5333cfa972b8512519abcc036a4b9d:All pass locally. The semantic implementation received Architect PASS at
fffc286b0b5333cfa972b8512519abcc036a4b9d. Delivery head97fda6aa7b5f85db747c066898fd3ef3c6d1dbebadds the portable TG22 sort, includesterminalin the staged JSR consumer closure, normalizes Linuxps's no-terminal spelling at the POSIX boundary, and declares the extracted terminal packages only in the private root test host. It also keeps the GN cross-process journey beneath its unchanged deadline by invoking the production TestAgent worker from a focused subprocess entry instead of repeatedly cold-loading unrelated CLI assemblies; real ACPX, controller transport, journals, lifecycle, and public CLI dispatch evidence remain intact. Fresh Architect reviews passed both delivery repairs.deno task verify:cleanalso passes, proving setup, offline builds, release compile, resolution, and concurrent Deno/Node/Bun consumption leave repository-owned dependency state unchanged.One Bun 1.4.0 package import failed once with
EISDIRduring overlapping local checks and passed unchanged alone and in independent review. It is tracked separately as flake #778; it does not weaken or close the package-boundary evidence.The real-provider proofs are opt-in because they spend account model turns. They remain frozen evidence and were not rerun during implementation or architectural review:
packages/acp/src/CodexNativeLaunch.test.mdpackages/acp/src/CodexZeroNativeTurnExit.test.mdFor a manual mixed-pane check, use one document containing explicit Claude and Codex
<Agent>scopes with uniquely named<Session.Launch>sessions. Run it twice from the same cwd: the first run must open both native UIs concurrently and announce exactly one Codex materialization; the second must resume both histories without another Codex materialization.Scope
Included
Intentionally unchanged
<Session.Launch>result capture (Return launch details from<Session.Launch>#753)New abstractions
provider-native-continuationis separate from native launch because proving that a native UI can resume a session does not prove ACP can later rejoin it.session-route.v3records the provider-returned construction route and immutable executable audit binding.Generated or mechanical changes
generated/snapshots.tscome from the reviewedfeat/session-native-identityadapter commit recorded in its manifest and provenance.codexas a placeholder agent are renamed so they do not accidentally exercise the now-real adapter.Risks and limitations
Scope confirmation