Uh oh!
There was an error while loading. Please reload this page.
Make Codex Desktop capture explicit and acceptance-tested - #425
Conversation
The Codex adapter has always captured Codex Desktop, by two routes: `hyp attach codex` writes the `~/.codex/config.toml` both the CLI and Desktop read, and the backfill provider reads the `~/.codex/sessions` rollout tree both write. Nothing in the product surface said so, and three things implied the opposite: the picker read "capture Codex conversations", Claude Desktop ships a dedicated setup (so desktop clients look like they need their own adapter), and the backfill provider flags `Application Support/Codex` as an unsupported location with no explanation. - Picker label, summary, plugin description, both hypaware-reference skills, README, and PRIVACY.md now name Codex CLI and Codex Desktop. - New LLP 0139 records why Codex Desktop rides the ordinary adapter while Claude Desktop needs its own (shared config file and shared rollout tree vs a root-owned managed plist), and where the unsupported boundary actually is. - The `codex_desktop_app` unsupported_location event and log now carry a `covered_by` attribute naming the live gateway route and `~/.codex/sessions`, so the flag reads as "this directory", not "this client". - New docs/ACCEPTANCE.md carries an opt-in, manual `codex_desktop_capture` procedure (a human, a real Mac, a real Codex Desktop). AGENTS.md points at it. `gateway_codex_capture` now states in its own header that its Desktop-shaped request is synthetic and proves nothing about a real app. Not done: making `hyp status` (or another product surface) report recent Codex Desktop traffic from `entrypoint`. `hyp status` boots with no plugins activated by design, so it has no dataset registry and no cache read, and putting client-specific knowledge in core cuts against LLP 0130 and LLP 0003. Every route out of that needs a design decision; the analysis is in LLP 0139's consequences. Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe
commented
Jul 28, 2026
Fan-in check. The reproduce gate holds independently: the three new tests were run against One thing to decide before merging, and it is bookkeeping rather than code. The body ends with Worth doing one of:
Item 5 is a genuine design question, not an oversight. An adjacent finding surfaced by that analysis, worth knowing because it is the same root cause on another client: And one honest limit on the premise, from the worker's own report: the literal string |
…a pass Renumber the new decision doc from 0139 to 0141: open PR #422 claims 0139 and 0140, so whichever landed second would have collided. Every @ref and prose link moves with it; both anchors still resolve. Also, from review: - docs/ACCEPTANCE.md step 5 asked for rows written by a backfill of a session step 3 already captured live. The materializer's part_id dedupe suppresses that duplicate, so a healthy system read as a failure. The pass condition now states outright that rows_written: 0 with rows_skipped >= 1 is the expected result. - The LLP's backfill bullet now marks its own confidence: the shared rollout tree rests on the provider's assumption and on fixtures, and the acceptance procedure is what confirms it on real hardware. - covered_by drops 211 characters of prose for two tokens, gateway_live,codex_sessions_rollout, so it stays queryable like every other attribute on the event. The prose stays in the LLP and README. - The release checklist points at docs/ACCEPTANCE.md when a release touched a client adapter. - Step 7 re-attaches, so the procedure does not leave an operator's own machine with Codex capture off. - Test 2 is renamed to what it asserts, and test 3 now pins covered_by on the structured log as well as the event. - The picker summary loses its trailing clause; walkthrough.js writes it as one unwrapped line. Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe
commented
Jul 28, 2026
Review round 1 - |
philcunliffe
commented
Jul 28, 2026
Review round 2 - |
| # | Item | Result |
|---|---|---|
| 1 | ACCEPTANCE step 5 pass condition | PASS - now asks items_seen >= 1 and rows_written + rows_skipped >= 1, and states outright that rows_written: 0 with rows_skipped >= 1 is a pass, with the reason. Internally consistent with the "re-running is safe" sentence, and --json really does emit those fields (commands/backfill.js:983-986). |
| 2 | LLP renumber 0139 to 0141 | PASS, checked exhaustively - see below |
| 3 | LLP backfill caveat | PASS - names the two actual grounds (the provider's long-standing assumption, and smoke fixtures that synthesize a Desktop originator), says it is not a verified capture, points at step 5. Specific rather than vague, and the "If it fails" branch names this bullet as the thing falsified. |
| 4 | covered_by shortened | PASS - 'gateway_live,codex_sessions_rollout' (backfill.js:143), reaches both the log (:317) and the event (:326), does not leak into --json, and no doc still quotes the old prose |
| 5 | Release checklist wiring | PASS - AGENTS.md:227-230, inside ## Release checklist itself, not just the test-model section |
| 6 | Operator left detached | PASS - step 7 now re-attaches with a conditional note |
| 7 | Test rename + log assertion | PASS, and the log assertion is load-bearing: mutation-tested by deleting only the log-side covered_by spread, which flips test 3 to failing |
| 8 | Picker summary trimmed | PASS - 211 to 163 chars, label unchanged at capture Codex conversations (CLI and Desktop) |
The renumber got the most scrutiny, since it is wide, mechanical, and the easiest thing to get subtly wrong. The file is llp/0141-..., its own title says 0141, both #one-adapter and #unsupported-boundary exist as real anchors, and all five @ref LLP 0141#... annotations resolve. A repo-wide grep for 0139 and 0140 across *.js, *.md, and *.json returns zero hits. The llp/ filename set differs from origin/master by exactly one added file, so no unrelated number was disturbed. And 0141 itself is free: master's highest is 0138, #422 claims 0139 and 0140, and none of the other eight open PRs touches 0141.
The trailer is Refs #421 with the parenthetical, so merging will not auto-close an issue whose item 5 is deliberately undone.
One cosmetic thing fixed in this act: the PR body's "what a reviewer should check closely" list still described covered_by as carrying "its long prose value" and asked for it to become a short key - work the review fixes had already done. A reviewer starting at the bottom of the body would have been sent after a resolved item. Rewritten to describe the current state.
Verification run
npm test: 2764 tests, 2755 pass, 8 fail, all the pre-existing leave-command.test.js set, still the only failing file, no growth. npm run typecheck clean. node --test test/plugins/codex-desktop-coverage.test.js: 3/3. npm run smoke -- gateway_codex_capture: ok.
Uh oh!
There was an error while loading. Please reload this page.
…item 5) (#502) * hyp status names recent clients from gateway-tracked entrypoints (#421 item 5) Issue #421 items 1-4 shipped in #425. Item 5 - "make `hyp status` or the verification surface identify recent Codex Desktop traffic when `entrypoint` or `originator` proves it" - was left open because closing it needed an architecture choice, recorded as four candidates in LLP 0141's consequences. The maintainer picked **option 4** on the issue thread: the gateway tracks last-seen entrypoints into `status.json` as traffic flows, and `hyp status` reports them with no cache read. - The AI-gateway source folds each committed batch of projected rows' `entrypoint` / `client_name` into an in-memory, bounded, LRU-evicting map (new `entrypoint_activity.js`), and exposes it as `recent_entrypoints` in its `status()` details. It counts and timestamps; it interprets nothing, so no client-specific string enters core (LLP 0130, LLP 0003). - The daemon re-reads started sources' `status()` details on every sink tick and once at shutdown. Boot captured them exactly once, which was enough for details fixed at bind time (host, port, LLP 0114 fallback marker) and is not enough for a detail that accrues. - `hyp status` renders a `recent clients:` block and `--json` a `recent_entrypoints` array, read from `status.json`. It is deliberately not liveness-gated: unlike a bound port, "last seen at T" stays true after the daemon exits, and the rendered age carries the staleness. - `claude-desktop verify` step 3 told users to "confirm capture via `hyp status`", which was not achievable. It now is, and the wording points at the line that answers it. Docs: new LLP 0164; LLP 0141's open consequence resolved; LLP 0086's status.json note corrected; `docs/ACCEPTANCE.md`'s `codex_desktop_capture` step 4 gains the query-free check plus its two expected-negative cases. Co-Authored-By: Claude <noreply@anthropic.com> * Bound and sanitize recent-client labels before they reach disk or a terminal `entrypoint` reaches the tracker by three routes, not the two the design assumed. Codex's `originator` header and the User-Agent product are both bounded by Node's HTTP parser (no C0 control bytes, ~16KB). The third is not: for Claude the *live* projector copies `entrypoint` off a transcript `.jsonl` line on disk (`applyTranscriptMatch` -> `assignTranscriptIdentity`, transcripts.js:517), and that is an ordinary JSON string of any length holding any byte, which then wins over the exchange-level value at message_projector.js:715. That value was stored verbatim, written to `status.json` on every tick, and printed verbatim by `hyp status`. Executed against the real binary: a 30KB transcript entrypoint carrying ESC and newline produced a 30375-byte status.json and terminal output containing a raw escape sequence and a forged `daemon: FORGED ALL GOOD` line. The 32-entry cap bounds the number of entries, not their size, so it did not prevent this. Adds `sanitizeLabel` to core util (strip C0/C1 and the Unicode line separators, clamp to 120 chars) and applies it in two places: - at the gateway tracker's `record`, so the map key itself is clean and the eviction cap cannot be diluted by values differing only in invisible bytes - at core's `recentEntrypointsFromSources`, because `status.json` is a file and core must not assume the daemon that wrote it was this build; this is the last point before render Same input after the fix: status.json 444 bytes, output 1139 bytes, no escape byte, no forged line, surface still named. Also corrects LLP 0164's "Bounded" rationale, which named only the header route while its own motivating example (`local-agent`) is the transcript one, and pins the default cap in a test (raising it to 100000 previously went uncaught, since every eviction test passes an explicit `max`). Co-Authored-By: Claude <noreply@anthropic.com> * Finish the label bound, and stop a plugin probe from freezing the tick loop Round-2 review of #502. Three gaps, all executed against the real tracker, the real core reader, and a real daemon. 1. `sanitizeLabel` stripped control bytes but not the two other classes that make a label render as something other than what it stores. Bidi overrides and isolates survived to the terminal, and an unterminated one reorders past the end of the label into the rest of the status line. Zero-width and default-ignorable characters survived to the map *key*, which is exactly what the tracker's own comment claimed could not happen: 500 variants of `codex-tui` that differ only in zero-width spaces filled all 32 slots and evicted every real surface. Both classes are now stripped, so those 500 variants collapse to one entry. Confusables stay out of scope on purpose. Also: the clamp appended its marker past the ceiling (120 chars in, 123 out) and could cut between the halves of an astral character, putting a lone surrogate into `status.json`. The marker is now inside the ceiling and a stranded high surrogate is dropped. 2. Core sanitized each label it read back but capped only their length, not their number, though the comment justifying the read-side sanitize is that this build may not have written the file. A status file with 5000 entries printed 5000 lines. Capped at 32 after the sort, matching the writer. 3. The per-tick refresh put plugin code on the tick loop's critical path with no bound. A source whose `status()` boots fine and then hangs froze `persist()`, so *every* field in `status.json` stopped updating while the daemon went on reporting itself healthy, and it hung `hyp daemon stop` outright (shutdown refreshes too). Probes now run under a timeout, a source with an outstanding probe is skipped rather than piling up an unclosed span per tick, and failures are logged once per transition (`daemon.source_status_failed` / `_recovered`) rather than never, which was the old `safeStatus` behaviour, or every tick forever. Verified by execution before and after: status.json keeps being written and the daemon still stops in ms with a permanently hung probe, and ~500 ticks produce at most 3 log lines, not 500. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: test <test@test.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: neutral <neutral@hyparam.dev> Co-authored-by: test <test@example.com>
Premise check (done first)
The issue asserts Codex Desktop is already covered. Confirmed in code before writing anything:
attach()inhypaware-core/plugins-workspace/codex/src/settings.jswritesdefaultConfigPath(), which is$CODEX_HOME/config.tomlor~/.codex/config.toml(src/config-file.js). That is the file Codex Desktop reads. Corroborating evidence in-tree:exchange-projector.jsacceptsx-codex-turn-metadataas a sufficient match signal with the comment "Codex Desktop tags requests with ax-codex-turn-metadataheader", and LLP 0083 states the header is "Codex Desktop behavior" as distinct fromcodex-tui.backfill.jsscans<codexHome>/sessions/**and mapssession_meta.originatoronto the row's first-classentrypointcolumn plusattributes.codex.originator.entrypointis a real column in theai_gateway_messagesschema (ai-gateway/src/message_projector.js).defaultUnsupportedLocations()did list~/Library/Application Support/Codexascodex_desktop_appwith no explanation.Premise holds. One qualification I did not paper over: the literal string
Codex Desktopfororiginatorappears only in fixtures/tests in this repo, not in any captured artifact I could verify. So nothing I wrote pins that literal. The acceptance procedure lists what a machine actually recorded instead of asserting a value.Per-item status
1. Picker, help, and reference docs state the Codex source covers CLI and Desktop - DONE.
codex/hypaware.plugin.json: pickerlabel->capture Codex conversations (CLI and Desktop);summaryand the plugindescriptionname both surfaces. Both label and summary are rendered by the walkthrough (src/core/cli/walkthrough.jslines 91-93), so this is a real product surface, not a comment.hypaware-referenceSKILL.md for bothclaudeandcodex(they are near-identical copies): the source list and thehyp attachentry.README.mdsource list, plus a new Desktop apps subsection.docs/PRIVACY.mdsource table.2. Why Codex Desktop uses the ordinary adapter while Claude Desktop needs its own - DONE.
New
llp/0141-codex-desktop-rides-the-codex-adapter.decision.md, with a table of the actual asymmetry (shared~/.codex/config.toml+ shared~/.codex/sessions/**vs a root-owned/Library/Managed Preferences/...plistand an embedded-CLI route landing underentrypoint: "claude-desktop-3p"). Summarised in README and in both reference skills.@ref LLP 0141#one-adapteronsettings.js'sattach(), which is the code that makes it true.3. The exact unsupported boundary - DONE, and made self-explanatory at runtime.
Documented in LLP 0141
#unsupported-boundary, README, and the reference skills. Also a behavioral change: thecodex_desktop_appunsupported_locationevent and its log now carry acovered_byattribute naming the live gateway route and~/.codex/sessions. The ChatGPT desktop-app flags stay bare on purpose - those conversations really are not captured by any route.4. Real or opt-in acceptance flow - PARTIAL, and honestly so.
I have no Mac with Codex Desktop. I did not write a smoke that pretends otherwise. What landed:
docs/ACCEPTANCE.md: a written, runnable, opt-in / manualcodex_desktop_captureprocedure - attach, quit and reopen Desktop, hold a conversation, then verify the live route by query and the backfill route by rollout independently, plus thecovered_byboundary check and a clean detach. Each step has a stated pass condition and a "if it fails" branch that says which of them would falsify LLP 0141 rather than which workaround to apply.AGENTS.md(CLAUDE.mdis a symlink to it) now splits "written acceptance procedures" from "candidates with no written procedure yet" and points at the doc.hypaware-core/smoke/flows/gateway_codex_capture.jsgained a "What this does NOT prove" paragraph: itsoriginator/ User-Agent /x-codex-turn-metadatarequest is synthetic, issued by the flow itself against a local fake upstream, and proves the projector handles that shape, not that a real Desktop routes through HypAware.No acceptance run happened. No result is claimed. Nothing in this PR should be read as evidence that Codex Desktop was exercised end to end.
5.
hyp status/ verification surface identifies recent Codex Desktop traffic - NOT DONE. Needs a human design decision.The analysis, recorded in LLP 0141's consequences:
hyp statusboots with no plugins activated, by design:decideBootProfileinsrc/core/cli/dispatch.jsreturns{ activate: [] }forstatus, andrunStatusdocuments the reason ("intentionally avoids activating configured plugins so the command does not bind local listeners just to render a report"). So status has no dataset registry and no cache read -collectHypAwareStatusreads config, the daemon status file, and client settings files, and nothing else.originatorvalues would put client-specific knowledge in core, which cuts against LLP 0130 ("rendering the picker needs no plugin code execution", and picker/client data is declarative) and LLP 0003's core/plugin split.Four ways out, each a design decision I should not make unilaterally:
hyp status's boot profile or give it a cache read (contradicts a documented deliberate decision).hyp query overview- the natural generic answer, covering Claude Desktop too, but it changes that block's calibrated window budget (OVERVIEW_SECTIONS.lengthfeedsrowsAffordable), and every constant there has a paragraph of rationale behind it (LLP 0135#window).status.json- a new runtime feature.Until one is chosen, the supported check is the query itself, which
docs/ACCEPTANCE.mdstep 4 spells out.Related finding, left alone deliberately:
hypaware-core/plugins-workspace/claude-desktop/src/verify.jsline 77 tells users to "Confirm capture: rows land under entrypoint 'claude-desktop-3p' ... (check via 'hyp status' or 'hyp mcp')". Thehyp statushalf of that is not achievable today for the same reason as item 5 - status shows no rows. It is Claude Desktop, not Codex, so I did not change it here; it is the same gap and should be fixed with item 5.Tests: real pre-fix failure output
New file
test/plugins/codex-desktop-coverage.test.js, three tests, run against unmodified source before any change:After the change:
# tests 3 / # pass 3 / # fail 0.Test 3 drives the real backfill provider against a path-faithful
Library/Application Support/Codexdirectory in a temp home and reads the yieldedBackfillEvent, so it covers the pathhyp backfill codexruns.Suite numbers (real)
npm testThe 8 failures are identical before and after and all pre-existing in
test/core/leave-command.test.js(not ok 799, 800, 801, 803, 804, 805, 806, 807). The count did not grow.npm run typecheck: clean, before and after.npm run smoke -- gateway_codex_capture: ok. Also re-ranbackfill_codex_fixtureandwalkthrough_picker_to_first_query(the two flows nearest this diff): both ok.Review fixes applied (commit 320f3e7)
Renumbered the new LLP from 0139 to 0141. Open PR #422 (unmerged) claims
both
0139-desktop-picker-consentand0140-transcript-entrypoint-ownership,and master's highest is 0138, so whichever of the two PRs merged second would
have collided. The doc is now
llp/0141-codex-desktop-rides-the-codex-adapter.decision.md; all five@refannotations and all four prose links moved with it, and both anchors
(
#one-adapter,#unsupported-boundary) still resolve.grep -rn "LLP 0139"returns nothing.
Also from review:
docs/ACCEPTANCE.mdstep 5's pass condition was self-contradictory. Itasked for rows written by a backfill of the session step 3 had already
captured live, which the materializer's
part_iddedupe suppresses. Thecondition now states outright that
rows_written: 0withrows_skipped >= 1is a pass, and why.below): the shared-rollout half rests on the provider's assumption and on
fixtures, not on a verified capture, and step 5 is the check that confirms
it. The README/SKILL.md restatements stay assertive.
covered_byis nowgateway_live,codex_sessions_rollout(reviewerpoint 2 below), not 211 characters of prose, so it stays key-shaped and
queryable like every other attribute on the event. The prose lives in the
LLP and README. Test 3 pins the new value on the event and on the
structured log, which was previously unpinned.
AGENTS.mdnow points atdocs/ACCEPTANCE.mdwhen a release touched a client adapter.
machine with Codex capture off.
surfaces": it only asserts on
manifest.description, and the clientdescriptor carries no prose.
163 chars, down from 211.
walkthrough.jswrites it as one unwrapped line.Re-verified after these fixes:
npm test2764 tests / 2755 pass / 8 fail (thesame pre-existing
test/core/leave-command.test.jsfailures, not ok 799-807,no growth),
npm run typecheckclean,node --test test/plugins/codex-desktop-coverage.test.js3/3,npm run smoke -- gateway_codex_captureok.What a reviewer should check closely
capture Codex conversations (CLI and Desktop)is the one user-visible string change in the walkthrough. If the house style prefers something shorter, this is the line to argue about.covered_byattribute value. Nowgateway_live,codex_sessions_rollout(shortened from prose during the review fixes, to match this repos preference for key-shaped attributes). It rides both theBackfillEventand the structured log, and does not reach--json`. The prose it replaced now lives in the LLP and the README.docs/ACCEPTANCE.mdcommand accuracy. I verified the flags exist (hyp backfill [provider] --since --dry-run --json,hyp query sql,hyp attach/detach codex) and thatunsupported_locationreaches the log stream rather than the command's JSON (hence theHYP_DEV_TELEMETRY=1+dev-telemetry/logs-*.jsonlstep). I could not execute any of it against Codex Desktop, so the shell is reviewed, not run.~/.codex/sessions. This is the load-bearing factual claim behind the backfill half. It is consistent with the provider's own long-standing JSDoc ("the rollout files the Codex CLI/Desktop writes") and with the fixtures, but the acceptance procedure's step 5 exists precisely to confirm it on real hardware.Refs #421 (item 5 is deliberately not done, so this must not auto-close the issue)