Repository navigation
Ask one Session how far it has got, and bound the answer once for both lanes (#76) - #101
Merged
Merged
Conversation
…76) Build 3/7 of the signed port table: P6, read-only progress inspection. This commit is the vocabulary and the two pure readers; the wiring follows. `Progress.recent` widens from `tuple[str, ...]` to `ProgressEntry(role, text)`. #76's shape asks for "bounded recent user/assistant entries" and #74's type carried no role, so porting the one into the other would have dropped it — and a roster of bare strings reads "make it blue" and "I made it blue" the same way, leaving every surface to guess. Legacy carried the role to the wire (`legacy@1d32845:bridge/transcript.py:40-46`). Widened through the seam's own rule rather than beside it (`seams/agent.py:35-41`). Advisor ruling on the ticket, Q4. `adapters/agent/_progress.py` holds the bound, and holds it once. Each lane finds its entries its own way; how many of them travel is a property of the type and of the wire, not of either lane. Three entries and 3 KB per row — legacy's 12 and 32 KB were a per-Session verb's (`config.plist:449-452`), where this rides on **every** roster row of a reply with a 64 KB ceiling (`control_plane/client.py:88-94`). The rule is ported whole from `legacy@1d32845:bridge/transcript.py:2828-2860`: keep the newest, widen down until it fits, say so when anything was dropped. `drop_oversize_tail` is the only behaviour here rather than an option — a reply nobody can read is worse than a row that admits it is empty. Advisor ruling, Q2. `claude/transcript_tail.py` reads the records `transcript.py` already opened. `Progress` and `last_activity` are two fields because they are two facts: a Session that spent a minute running tools has moved without saying anything a reader would show, so the time comes off *any* record — a subagent writing there is this Session running — and the entries only off the conversation. `stop_analysis._speaks` becomes a public `visible_text`, and `_is_visible` / `_is_pipeline_noise` become public with it. `_speaks`'s own docstring already pointed at this ticket as "its own reader"; answering the tail boundary from a second scan of the same content is how the reference implementation ended up with readers that disagreed about one record. One extractor now, and the boundary is `bool(visible_text(...))`. No behaviour change, proved by #75's 88 tests passing untouched. `codex/thread_tail.py` is the same reading over what the shared daemon says. Item selection ported from `legacy@1d32845:bridge/codex.py:1465-1520`; **adapted** in one place, deliberately — legacy *raised* on an item type it did not recognise (`:1519`), which was right for a verb about one Session and wrong for a reading that now rides on a roster row, where one unknown item would blank a row the user is looking at. `turn_id` and `turn_status` are **dropped, because** no v1.0 consumer reads them (`:1484-1492,1516-1520`). Every Codex shape here was measured against the real daemon on 0.149.1 (2026-08-26), not assumed: turn items are `agentMessage` / `userMessage` / `reasoning` / `commandExecution`, and `updatedAt` is epoch **seconds** (`1787712279` = 2026-08-26T02:44:39Z) where legacy's reader never read a time at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`CodexAgentAdapter._shared_daemon()` returned `None` unconditionally, so every Codex row came off the process table and no thread could be read at all — which made progress on that lane `None` by construction and its acceptance step red for a reason no amount of building #76 could reach. #82 established the daemon as the only source of a thread's own truth and #83 installed the login job that starts it; nothing ever connected the two. Advisor ruling on the ticket, Q1: the client is one thing, and whoever needs it first builds it once. `adapters/agent/codex/shared_daemon.py`, read side only. The address comes from the daemon itself — `codex app-server daemon version` names the socket it is listening on, which cannot go stale under a moved `CODEX_HOME` or an updated managed binary — ported from #82's prototype (`scripts/prototype_codex_daemon.py:51-68,442-454`), where the shape was proved against a real daemon. The transport already existed: `AppServerConnection` speaks WebSocket-framed JSON-RPC over a Unix socket, and `process.attach()` is the become-one-more-client verb, private-socket checks included. **Join-only, and that is a rule rather than a scope note.** This product starts a daemon the user's Sessions will join and never stops one they are attached to (#83, ADR 0012): by the time the engine shuts down, the user's `codex` TUIs are its clients. `aclose` lets go of this engine's end and nothing else. Asserted on what the module *runs* rather than on its source text — the one daemon subcommand it asks for is the one that only reports. **Against legacy: dropped, because gen 1 had no shared daemon to join.** It drove a launched, wrapped, per-Session app-server it owned (`legacy@1d32845:bridge/codex.py:1319-1347`). What survives is the shape of the read side: locate, connect, ask, and never fall back to another source. No version pin (Simon's ruling on #67): a CLI and a daemon that disagree still get dialled, and the disagreement rides out as a note on `LaneDiscovery. degraded` rather than the lane going quiet. Both versions are checked for being *said* before they are compared, because a document carrying neither field would otherwise report a daemon that said nothing as one whose versions agree. The address is looked up only when there is no live connection, so a healthy engine spawns no subprocess per tick; an engine whose daemon is down probes once per discovery, measured at 139 ms, which is the only honest way to notice it came back. A connection the far side dropped is never reused — the daemon's own updater restarts it. Proven live, read-only, on this machine: located `~/.codex/app-server-control/app-server-control.sock`, `cliVersion` and `appServerVersion` both `0.149.1`, a real `AppServerConnection` through `attach()`'s checks, and `discover()` answering `enumerated=True, degraded=None` where every run before it carried the no-daemon note. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…and (#76) Both lanes now answer `Progress` and `last_activity`, and they answer them in two different places on purpose. **The roster is the cheap projection.** `discover` runs every five seconds over the whole machine, so it reads only rows that are not mid-turn — #75's rule, which #76 rides on rather than widening. On the Claude lane that costs nothing new: the same one transcript read already open for #75 answers both questions at the same moment, which is the whole reason `transcript.py` exists. On the Codex lane it costs a great deal, and the number is why `TurnCache` is here. Measured against the real daemon on 0.149.1 (2026-08-26): `thread/read` with `includeTurns: true` answered **558,875 bytes** for a thread of two turns, where the same read without them answered 3,600 — and `numTurns` is not a parameter of that method, so there is no smaller answer to ask for. Gated but uncached, a machine of three stopped Sessions would spend ~1.7 MB a tick on an answer that had not changed. So the reading is cached on the thread's own `updatedAt`, which is the argument `transcript.py` already makes from `(size, mtime_ns)`: a thread nobody has touched cannot have changed what its tail says, and the key is the far side's own account rather than a clock this module would have to guess the length of. A thread naming no `updatedAt` is never cached; the cache is pruned to the daemon's roster each tick. `last_activity` comes off the *cheap* read, so a thread mid-turn has one even though it has no progress — which is exactly the case the field exists for. **The verb reads now, and reads whatever state it finds.** `inspect` has no `RUNNING` gate, because "how far along is it" is the question a user asks precisely while a Session works. Advisor ruling on the ticket, Q3. Making it ungated exposed a bug the gate had been hiding: on a working Session the transcript tail routinely holds a `tool_use` with no result yet, and that is a tool *running*, not a dialog waiting on the user. `_read_into` reads a `RUNNING` row for progress and never for a stop. Nothing bypasses the Claude reader's cache, and nothing needs to — a hit means the file has not been written to since, so it *is* the freshness proof, and forgetting the parse would cost a second pass over a file measured at 186 MB here for no gain. The ruling was amended on the ticket to say so. Codex takes exactly one deep read per ask, and getting there took two goes. `inspect` enumerates before it answers, so the cadence's read is part of what one ask costs; the first draft paid for two. It now drops that thread's remembered turns *before* enumerating, so the enumeration takes the one fresh read and `_turns_into` keeps a reading the row already carries. A thread mid-turn is gated out of that read and picked up by `_turns_into` — still one. An unattached row keeps `progress=None` and never a guessed one: its rollout is on disk and reading it would be a second source answering with worse evidence, which the port table left behind (P6, P13). `_shared_daemon()` is restored as the lane's single door to the daemon, because there is what its pre-wire `FAILED` means. The process table is injectable for the reason the daemon is: the test that proves one-read-per-ask has to run the real enumeration, and a unit test that shelled out to `ps` would answer differently depending on what the person running it has open. `FakeAgent.inspect` now answers `ENDED` for a Session it did not find, which is what both real adapters answer — a fake that returned a live row let a caller pass a test the product fails. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…can (#76) `progress` is the eighth control-plane action and the last piece of P6: the port table asked for "a thin Core/control-plane `progress` verb" over the inspection type, and `bridgectl progress <agent>:<id>[:<pid>]` is it. `BridgeCore.progress` resolves one exact identity, asks that lane and no other, and returns the same `Session` row `status` renders — the reference implementation's own rule, ported: `legacy@1d32845:bridge/daemon.py:2202-2271` resolved one registered identity, asked only that agent's authority, and never fell back to another lane, a terminal or a screen. It is a read and costs no turn, which `core/router.py:31-32` already said of the whole class. **It refuses rather than answering emptily**, which is #76's "honest error for an unattached or ended row" and the line the whole action is drawn around: - The lane could not be read at all — `LaneUnreadable`, carrying the lane's own words. The roster's row stands: not being able to look is not a sighting. - The Session has ended — `StaleSessionError`. - Nothing could read how far it has got — `ProgressUnavailable`: an unattached Codex Session, or one whose first turn has written no record yet (#73). A Session that *was* read and had said nothing is none of those. It answers normally with an empty reading, and a surface handed the second as the first would render a working Session as an idle one. `ProgressUnavailable` is ported from legacy's own rejection for this verb (`bridge/daemon.py:2222-2246`), adapted from a reply document to a refusal on the closed error set. **The verb ends nothing.** `SessionRegistry.observe` stays the only thing that ends a row, which is what its own docstring claimed and what an early draft of this verb broke: the value `inspect` answers for a Session it could not find carries no workspace and no name, so folding it in stripped the very fields a surface needs to say what happened. A later reader will be tempted to "fix" this; it is deliberate, and the next discovery ends the row within one cadence. `_admit` is promoted to the public `observed_one` rather than wrapped, so there is one way in and it says what it does not do. `PROTOCOL_VERSION` stays 4. A version lets a surface tell an engine that disagrees from one too old to have been asked, and what it guards against is a surface sending something the engine does not have — which adding an action cannot cause. Removing one can, and that is what 4 was for. The Swift shell gains `case progress`, and `tests/test_app_bundle.py` now holds the two action sets to each other. `Wire.swift`'s own comment said "Seven, and the set is closed" with nothing checking it against the engine — #47's shape, one file over. Verified the regex finds all eight rather than passing vacuously. The Companion Channel's `/progress` and the Delegated Turn's command card come free: both are generated from the closed set, so neither can grow a command the other lacks. The acceptance step now runs `bridgectl progress <target>` — the ticket names that command — and cross-checks it against the roster row, because #76 built both surfaces and they are one reading. It still proves the read costs no turn the only way it can from outside: the agent's own record does not grow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ce (#76) Three findings from the Codex-lane review of the committed branch (base `37cf18e`, spec axis `8e858938-2903-42ac-a86b-cddeeca2173e`). Two are defects and one is a ruling written down. **A `codex` that never answers stopped both lanes' rosters.** `_run` awaited `communicate()` with no bound, and `core/bridge.py:505` awaits `discover()` with no deadline of its own — one loop over the adapters, so a subprocess hung here took the *Claude* lane's roster with it, for as long as that process lived. The lane's other subprocess reader has carried this bound since it was written (`codex/processes.py:175-181`) and so has the Claude lane's (`claude/discovery.py:94-100`); this one did not, and nothing said why. It is the same ten seconds, in the runner rather than at the call site, and the process is killed rather than abandoned — the wait ending is not the command ending, and the next tick would otherwise start another one beside it. The two handlers in `locate` are ordered, and the ordering is the behaviour: `TimeoutError` **is** an `OSError`, so caught second a daemon that hung was reported as a `codex` that is not installed. Pinned by a test that asserts the reason it must *not* give. **Two callers dialled twice.** `client()` read the connection, awaited the lookup and the attach, and only then wrote what it had made. The engine has two callers that arrive independently — the five-second cadence and a control-plane `progress` ask — so both could find no connection and both attach: two clients of a daemon this engine is meant to be one client of, with the loser dropped and nothing left to close it. Now one dial at a time, asked for twice: a live connection still answers without taking the lock, and a caller that waited asks again, because what it waited on is very likely the dial that answers it. `aclose` takes the same lock, so shutdown waits for a dial in flight instead of having it write its connection back afterwards. The fake attach yields, because a fake that returns without ever awaiting hands the loop to nobody and a race cannot happen in a test that never lets the second caller start. Both tests fail against the pre-fix code. **`last_activity` counts a sidechain record, and that is now written down.** #76's body says the helper excludes "child/sidechain, system and command-plumbing records as legacy does"; `recent`'s entries do, and `last_activity` deliberately does not. Advisor ruling on #76, and the reasons are in the docstring rather than here: the field means *this Session moved*, a Task subagent writes into this transcript and has no row of its own to own that time, and the Codex lane's `updatedAt` counts all work — one meaning on both lanes. **Adapted** rather than dropped: legacy's sidechain exclusion (`legacy@1d32845:bridge/transcript.py:1125-1142`) is a *visibility* rule, ported whole into `recent`'s entries and into `stop_analysis.analyse`; `last_activity` is not a visibility fact, and gen 1 had no such field to cite either way. A sidechain record is also not #68's Child Process — that is a separate `claude` process with a row of its own — and the docstring names the distinction so the two stop being conflated. No behaviour change; the existing test stays the pin. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rebase onto `663cf1b` put this branch's `SharedDaemon` beside #96's shutdown, and two things had to be reconciled rather than merged. Both are here; the textual conflicts are in the rebased commits where they arose. **`aclose` no longer waits for a dial in flight.** It took the dial's lock, which is the obvious way to stop a dial writing its connection back after the engine had let go — and it is wrong under #96's arithmetic. `runner.SHUTDOWN_SECONDS` is sixteen because the phases it bounds sum to 13.2 and it must exceed them, checked by a test that computes the sum from the constants rather than from a comment. A phase that can sit out a ten-second daemon lookup is not one that sum has room for, and overrunning the grace does not buy a tidier stop — it buys a SIGKILL with nothing written down, which is the exact leak #96 exists to end and which #96 already once reintroduced through arithmetic. So the dial is invalidated instead of waited on: `aclose` bumps a generation and returns, and a dial that finishes on a stale one closes what it made rather than writing it back. Clearing the field alone would have left that client with nothing holding it and nothing to close it. Two tests, both verified red against the lock — one on the leak, one holding the *ordering*, so a later change that reaches for the obvious lock fails instead of quietly spending the budget. **`NO_CLIENT` had to stop saying something that is no longer true.** #96 split that sentence out of `NO_DAEMON` because `_shared_daemon()` returned `None` unconditionally, so a roster reading "the daemon did not answer" was produced without a byte being sent while `bridge-install status` dialled the same daemon and got an answer. #76 builds the client, so "this build does not connect yet" is now the false half. What survives is #96's rule, which is the part that mattered: never claim the daemon was silent, and never claim anything about it this build did not observe. `NO_CLIENT` is now "could not be dialled", and it is the *fallback*: every path in `SharedDaemon.client()` that answers `None` leaves its own reason on `note`, and `_degraded` prefers that reason rather than printing both. "Could not be dialled; codex did not answer within 10 seconds" is two sentences making one claim, and a roster that makes more claims than it observed is what #96 is the record of. #96's test keeps its name and now holds the rule instead of the old spelling; a second test holds the preference. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 26, 2026
`NO_CLIENT` now reads "this engine holds no connection to the shared Codex app-server daemon", which is the reading the Advisor fixed on #96 ("consequence for part B", 2026-08-26) and which the previous commit here approximated rather than used. The difference is not decoration: "could not be dialled" asserts an attempt was made and failed, and a client `None` is not a witness to that — it is a fact about this process and nothing else. The claim that the daemon was dialled and stayed silent is `NO_DAEMON`'s, and it is only ever said after a request was really made. That ruling also named the sentence #96 must not write, and #96 wrote it: "this build does not connect to the shared Codex app-server daemon yet" was true for one day and false the moment #76 built the client. A test now holds that spelling out by name, so it cannot return under a later ticket that has forgotten why it went. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #76. Build 3/7 of the signed port table: P6, read-only progress inspection, plus the control-plane verb over it.
Provenance
37cf18e(the branch was written there; rebased onto663cf1bbefore this PR)3a79826c-7fa0-487b-b5d7-0cc1e79b67ff— no hard violations8e858938-2903-42ac-a86b-cddeeca2173e— 4 findings, all closed (re-review)20260826T074208Z, bundle byte-identical to2dfcefe.github/workflows/ci.ymlrun:lines greenAdvisor rulings cited, by ticket comment on #76 — not by this document:
progressverb reads live whatever the state. Amended on measurement: the Claude transcript cache is not bypassed, because a(size, mtime_ns)hit is itself the proof of freshness.Progress.recentcarries the role:ProgressEntry(role, text).last_activitycounts a sidechain record;progress.recentdoes not.Two decisions made in-session and upheld:
PROTOCOL_VERSIONstays 4 (adding an action is additive in the direction the version guards; removing one is not), and the reviewer's "one action touches six dispatch tables → use an action descriptor object" was declined, with no ticket.What is here
Progress.recentwidens toProgressEntry(role, text), because a roster of bare strings reads "make it blue" and "I made it blue" the same way.adapters/agent/_progress.pyholds the bound once, for both lanes: how many entries travel is a property of the type and of the wire, not of either lane.Both lanes answer
Progressandlast_activity, in two different places on purpose. The roster is the cheap projection —discoverruns every five seconds over the whole machine, so it reads only rows that are not mid-turn. The verb reads now, and reads whatever state it finds, because "how far along is it" is the question a user asks precisely while a Session works.On the Codex lane that gate is worth 558,875 bytes: measured against the real daemon on 0.149.1,
thread/readwithincludeTurns: trueanswered that for a thread of two turns where the same read without them answered 3,600, andnumTurnsis not a parameter of that method. HenceTurnCache, keyed on the thread's ownupdatedAt— the same argumenttranscript.pyalready makes from(size, mtime_ns).CodexAgentAdapter._shared_daemon()stops returningNoneunconditionally. Join-only (#83, ADR 0012): this product starts a daemon the user's Sessions will join and never stops one they are attached to.bridgectl progress <target>is the eighth control-plane action. It refuses rather than answering emptily —LaneUnreadable,StaleSessionError,ProgressUnavailable— because "nobody could read it" and "it has said nothing" are different facts, and a surface handed the first as the second renders a working Session as an idle one.One thing a reviewer will be tempted to "fix" — it is deliberate
BridgeCore.progressends no roster row, even when the lane reports the Session gone.SessionRegistry.observeis the only thing that ends a row. The valueinspectreturns for a Session it could not find carries no workspace and no name, so folding it in strips the fields a surface needs to say what happened. The next discovery ends it properly within one cadence.Acceptance: what moved, and what is gated elsewhere
gpt-voicecoding-0fran this same suite twice at2a7f535and got identical cards step-for-step on both lanes, so anything that moved here is this diff.2a7f535(×2)2dfcefeprogressprogress for claude:…:98262 is None after a turn that rancodex:None:17102codex:01a03d13-9906-73e1-90c1-1e6820021b97:86492claude
progressevidence:The second row is Q1 landing:
engine.logsaysjoined the shared Codex daemon at ~/.codex/app-server-control/app-server-control.sock, and the roster row's session id is the daemon's own thread id. Onmainno Codex Session could be identified at all.Codex
progressis red, and the cause is not in this diff. The chain, established rather than inferred:journey.py:433-441—stable_name()raisesStepFailedbefore it reachesself._drive_turn("acknowledge", ACKNOWLEDGE).stable namefails:has no name in the roster after 3 reads — #78 requires the official one.stop noticereports exactly that and skips the remaining five steps.turns: []andname: null. The Session genuinely never said anything.progresscannot go green for any diff until a name exists, and the name is Build 5/7 — Session naming: official names, immutable labels, self-report only for unnamed Codex #78's.Advisor ruling (Q6): this lands, and re-verifying the codex half is an explicit exit item on #78, with run
20260826T074208Zas the evidence.One observation, reported unclosed
During the run,
thread/readwithincludeTurns: truefailed 3/3 on the daemon-held thread:thread/read failed: list_turns is not supported yet. Read-only against the same thread after the TUI exited, the same call succeeds. The obvious hypothesis is that the daemon proxies a live thread to its owning app-server; the mechanism is not confirmed — three attempts to construct the live case failed, because a hand-startedcodexregistered no thread with the daemon within 90–120s. It did not cause the red (turnswere[]regardless). Advisor: no ticket — an unconfirmed mechanism is an observation. Recorded on #78's exit item and cross-noted on #82.Every other claude step is unchanged from
mainand belongs to #77–#80.Reconciling with #96, which landed while this was in review
Two semantic conflicts, not textual ones:
acloseno longer waits for a dial in flight. Taking the dial's lock is the obvious way to stop a dial writing its connection back after the engine let go, and it is wrong under The Codex-lane engine does not exit on SIGTERM and orphans its app-server, so a second acceptance run on one machine always fails #96's arithmetic:SHUTDOWN_SECONDSis sixteen because the phases it bounds sum to 13.2, checked by a test that computes the sum from the constants. A phase that can sit out a ten-second daemon lookup is not one that sum has room for, and overrunning the grace buys a SIGKILL with nothing written down. The dial is invalidated instead —aclosebumps a generation and returns, and a stale dial closes what it made.NO_CLIENTstopped saying something no longer true. The Codex-lane engine does not exit on SIGTERM and orphans its app-server, so a second acceptance run on one machine always fails #96 split that sentence out because_shared_daemon()returnedNoneunconditionally; Build 3/7 — Progress inspection: the progress verb over the locked inspection type #76 builds the client, so "this build does not connect yet" became the false half. This was not a judgement call I had to make: the Advisor's note on The Codex-lane engine does not exit on SIGTERM and orphans its app-server, so a second acceptance run on one machine always fails #96 ("consequence for part B", 2026-08-26) had already ruled the wording — a clientNone"must read as no connection to the shared daemon in this process", and "do not write a this build does not connect yet sentence that Build 3/7 — Progress inspection: the progress verb over the locked inspection type #76 makes false a day later". The Codex-lane engine does not exit on SIGTERM and orphans its app-server, so a second acceptance run on one machine always fails #96 wrote it anyway.NO_CLIENTnow uses the ruled reading, a test holds the forbidden spelling out by name, and the dial's own reason replaces the fallback rather than following it.Overlap with
main's new commits iscodex/adapter.py,codex/discovery.pyandcore/bridge.py; none of #96'sprocess.py,server.pyor the Telegram adapter. The acceptance was re-run after the rebase rather than carried over from before it.The acceptance is at
2dfcefeand the tip is1bcf34f. That one commit changes the text of adegradedconstant on the Codex lane and its test, and nothing else — no graded step readsdegraded, and the claude lane never sees it. Said out loud rather than left for a reader to work out, because "the tree moved under the evidence" is the failure this branch has already paid for twice.Against the reference implementation
Ported: the tail read (
legacy@1d32845:bridge/transcript.py:1184-1246), the bounding rule (:2828-2860), the Codex item selection (bridge/codex.py:1465-1520), the verb's one-identity-one-lane rule and its refusal (bridge/daemon.py:2202-2271,2222-2246). Adapted: legacy raised on an unrecognised Codex item, which was right for a per-Session verb and wrong for a roster row; its bounds were a per-Session verb's (12 / 32 KB) against a reply with a 64 KB ceiling. Dropped: the shared-daemon client has no legacy counterpart — gen 1 drove a per-Session app-server it owned; andturn_id/turn_status, because no v1.0 consumer reads them.🤖 Generated with Claude Code