Skip to content

Add first-party OpenCode CLI and Desktop capture - #998

Merged
philcunliffe merged 8 commits into
masterfrom
codex/opencode-cli-desktop-capture
Aug 25, 2026
Merged

Add first-party OpenCode CLI and Desktop capture#998
philcunliffe merged 8 commits into
masterfrom
codex/opencode-cli-desktop-capture

Conversation

@philcunliffe

@philcunliffephilcunliffe commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add the bundled first-party OpenCode adapter for both CLI and Desktop
  • install a HypAware-owned global JavaScript plugin for live SDK snapshots and use bounded exact-session exports for recovery
  • preserve native session, message, part, and tool IDs so live and replay converge, while applying privacy gates before persistence
  • add an intrinsic endpoint-free client registry, managed-file attach/detach support, XDG-aware setup detection, source health, tests, smoke coverage, LLP 0306, and manual acceptance steps

Testing

  • npm test (5,231 passed, 2 skipped)
  • npm run typecheck
  • npm run smoke -- opencode_capture
  • npm pack --dry-run
  • node scripts/llp-numbers.js check
  • git diff --check

Notes

  • stacked on Forward Claude OTEL datasets to central #997 so this review contains only the OpenCode change
  • real CLI/Desktop acceptance was documented but not run; no user OpenCode config, paid model turn, or session was touched
  • native OTLP remains optional health/attribution only; proxy and gateway work are intentionally deferred

philcunliffeand others added 5 commits August 24, 2026 14:38
… docs
Review round 1 on PR #997 at d20d807.
- `exportBatch` resolved the wire target OUTSIDE the per-partition try.
`forwardingTarget` now throws (unresolvable dataset), and that throw
escaped `exportBatch` into the driver's outer catch, which respools the
whole batch and reports zero exported, discarding partitions already
POSTed in the same loop. Resolve inside the try so one bad partition
costs one partition, which is the contract the loop documents.
- The registration path escaped the dataset name and the ingest path did
not, so with an arbitrary dataset name in `/v1/ingest/{signal}` the two
calls could name different resources (and `joinUrl`'s `new URL()` would
normalize a `..` segment out). Escape both the same way; the four legacy
signals are encode-invariant, so their URLs are unchanged.
- `TELEMETRY_EVENTS_SOURCE_SIGNAL`'s docstring still said rows POST to
`/v1/ingest/claude_telemetry` and that the declaration exists so
forwarding "never falls back to the dataset name", which is now exactly
what happens. Corrected; its LLP 0255 ref still holds.
- `proto.md` documented no `PUT /v1/datasets/{name}` and still said
`{signal}` is one of four. Document the announce call and the two ways a
signal now resolves, and mark the server side unverified from this repo.
- `isCurrent`'s JSDoc said two things can go stale; there are three now.
Document the mode axis, and give `ActionMarker.mode` a typed field
beside its `endpoint` / `assets_key` siblings instead of leaving it
`unknown` under the index signature.
- Tests pinning the claimed bounds rather than restating them: the
announce happens once per sink instance across two ticks; a rejected
announce fails the partition, never reaches ingest, and is NOT
remembered (so the next tick re-announces); an unresolvable dataset
fails only its own partition.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe
philcunliffeforce-pushed the codex/opencode-cli-desktop-capture branch from eaf363b to 3a16446CompareAugust 24, 2026 22:43
@philcunliffephilcunliffe added neutral:adopt Foreign PR adopted into neutral's reconcile scope neutral:adopted Adoption completion record: merged while carrying neutral:adopt (LLP 0031) labels Aug 24, 2026
…eam (#998 review)
Verified against a real OpenCode 1.18.22 server, not a fixture we wrote.
The installed plugin called `client.session.get({ sessionID: id })`. The
generated OpenCode SDK client takes route parameters under `path`, so
`/session/{id}` went out unsubstituted and the server answered 500. Every
snapshot carried an error envelope, which the listener could only report as a
missing cwd. The live lane recorded nothing at all and said so in a counter
that meant something else.
Reproduced and fixed, then re-verified end to end: a real `session.created`
now delivers the session and its messages to the listener.
Second defect, reproduced against the listener: OpenCode messages mutate under
a stable id, while the shared projected-exchange writer dedupes at message
grain. The first snapshot of an in-flight turn froze its streaming prefix, and
neither a later snapshot nor the export lane could replace it (the recovery
seed reads the same committed message ids). A turn observed mid-stream was
permanently truncated with its tool result missing. The projector now waits for
a settled assistant message, which is the message-grain form of the part-grain
rule already there, and for the same reason: whatever lands first wins forever.
Also:
- `message.part.updated` carries the part alone, so the highest-frequency
wake-up in the stream resolved to no session id and was dropped.
- Two smoke flows counted bundled plugins and picker rows by hand and were not
updated for the new adapter; both failed on this branch and pass now.
- The exact-id recovery path dropped a `.hypignore` session with no event, so
the withhold was invisible in the run report.
- `attach --dry-run` claimed it installed a file and asked for a restart.
- CLAUDE.md gained the acceptance procedure entry and the smoke battery line;
the acceptance doc's second import window was degenerate and would have
passed without testing the dedupe it exists to test.
Regression tests cover both headline defects and fail without the fixes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Base automatically changed from integration/claude-otel-central-forwarding to masterAugust 24, 2026 23:55
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Review round 1 - findings, fixed and pushed

Reviewed head 3a16446c against its real base integration/claude-otel-central-forwarding (merge-base f07c1617), not master. Both a /code-review pass and an independent whole-file pass; each caught things the other missed.

I did not review this against fixtures alone. The two headline findings were confirmed by running the adapter against a real OpenCode 1.18.22 (opencode-linux-x64@1.18.22, latest on npm, the exact version LLP 0306 names) and against the listener itself. Fixes pushed to codex/opencode-cli-desktop-capture as 7451cf6c.

Findings

1. CRITICAL - the live lane captured nothing.hypaware-core/plugins-workspace/opencode/assets/hypaware.js:33-34

The installed plugin called client.session.get({ sessionID: id }). The generated OpenCode SDK client takes route parameters under path (SessionGetData = { path: { id }, url: "/session/{id}" }), so the request went out with the placeholder unsubstituted. Real server log:

level=ERROR message=failed ref=err_75eaee80
error="Expected a string starting with \"ses\", got \"%7Bid%7D\""

Both reads 500, and value() returned the failure envelope, so the POSTed snapshot was {"session":{"error":{...}},"messages":{"error":{...}}}. The listener found no session.directory and counted it as missing_cwd - so the adapter recorded zero rows and reported it under a counter that means something else. Nothing in the PR could catch this: the hermetic smoke and every test POST hand-written snapshots straight at /snapshot and never exercise the asset.

Fixed, and re-verified end to end against a real session.created:

/snapshot {"session":{"id":"ses_fc9d2d821ffeoh6kb5X9P13Joa","directory":"...","time":{...}},"messages":[],...}

Also: a failed SDK read now drops the snapshot instead of shipping the error envelope, so missing_cwd keeps meaning "OpenCode reported no directory".

2. HIGH - a turn observed mid-stream was permanently truncated.hypaware-core/plugins-workspace/opencode/src/projector.js:64

OpenCode messages mutate under a stable id: text streams in and tool parts settle after the message id already exists. The shared writer dedupes at message grain (message_projector.js:764 skips the whole message on state.seenMessages.has), so the first snapshot of an in-flight turn froze its streaming prefix. Reproduced against the listener at 3a16446c:

snap1 (mid-turn) rowsWritten: 2
snap2 (settled) rowsWritten: 0
rows: [ ses_x-up "go", ses_x-text "Read" ] # tool result never lands

The complete text and the completed tool_result were dropped forever. The recovery lane cannot repair it either - scanCommittedMessageIds seeds seenMessages from the same committed message_ids. This defeats the PR's central convergence claim and it silently defeats the part-grain deferral the code already had: deferring a running tool part only works if a later snapshot can add it, and it could not.

Fixed by waiting for a settled assistant message (time.completed, finish, or error - verified against the SDK's AssistantMessage). Same rule as the part-grain one, one level up, for the same reason: whatever lands first wins forever. After:

snap1 (mid-turn) rowsWritten: 1 # the settled user message only
snap2 (settled) rowsWritten: 2 # full text + tool_result
snap3 (replay) rowsWritten: 0

3. HIGH - two smoke flows failed on this branch.hypaware-core/smoke/flows/cli_bundled_plugins_activated.js:339, walkthrough_picker_to_first_query.js:468

Both count bundled plugins / picker rows by hand and were not updated for the new adapter. cli_bundled_plugins_activated (plugins_skipped=6, actual 7) and walkthrough_picker_to_first_query (sources_available=8, actual 9) both failed at 3a16446c and pass at the merge-base, so they are this PR's regressions. Both are on the release checklist; CI does not run them. Fixed.

4. MEDIUM - message.part.updated wake-ups were dropped.assets/hypaware.js:13-19

sessionID() checked five shapes but not properties.part.sessionID. Per the SDK's Event union, EventMessagePartUpdated carries { part, delta } with no info and no top-level session id, so the highest-frequency wake-up in the stream resolved to undefined and returned early. Fixed.

5. MEDIUM - open, not fixed: endpoint drift on the endpoint-free attach path.src/core/config/action_attach.js:288

isCurrent() now skips the endpoint comparison entirely for requiresEndpoint: false, and perform() records detail = {}. But OpenCode's attach does bake a drift-prone endpoint into the file it writes (http://127.0.0.1:${opencodeListenPort(ctx.config)}). Change opencode.listen_port and the reconciler sees an unchanged assets_key, never re-attaches, and the plugin keeps POSTing at the old port into the asset's bare catch {}, while hyp status still reports attached: true because the ownership marker is present. This is exactly the hazard LLP 0086's endpoint-aware markers exist for; the endpoint-free path dropped the protection but kept the hazard. Manual hyp client attach opencode always rewrites, so this is the reconciled path only, and the default port is fixed. Left open: the honest fix is for the marker to record the adapter's own endpoint, which extends what LLP 0306 settled rather than correcting a slip, so it wants a human decision rather than a review-rung patch.

6. LOW - exact-id recovery dropped a .hypignore session silently.backfill.js:197. The post-export policy check did a bare continue while the pre-export check yields a usage_policy_drop event; on the exactSessionIds path item.directory is never populated, so that was the only check and the withhold was invisible in the run report. Fixed.

7. LOW - attach --dry-run claimed it installed a file.src/index.js:96. Rendered Installed <path>. Restart OpenCode to load it. for a run that wrote nothing. Fixed.

Checked and found clean:managed_file probe/detach round trip, the XDG branch in resolveClientSettingsPath (containment via withinBase preserved), mergeClientRegistries fallbacks, no remaining readers of state.clients outside api.js, assets/ inside the published hypaware-core/ file set, and port 4320 not colliding with 4318/4319. No semicolons, no em dashes, no inline import() types, no @typedef, root-anchored specifiers throughout.

One finding I disproved. The review pass flagged the backfill reading item.updated / item.created at the top level as the OpenClaw #552 defect repeated, since the SDK's Session nests them under time. It is not: opencode session list --format json really does flatten, verified against the real CLI:

[{ "id": "ses_...", "title": "probe", "updated": 1787615096538,
"created": 1787615096538, "projectId": "global", "directory": "..." }]

I also confirmed opencode export <id> writes its Exporting session: banner to stderr, so reading result.stdout and parsing it as JSON is correct.

(a) Stacked base

The diff is coherent against integration/claude-otel-central-forwarding and I found nothing that only works because of #997 or conflicts with it. The one real interaction is ClientRegistration being extracted from AiGatewayClientRegistration in hypaware-plugin-kernel-types.d.ts; #997's central sink reads dataset.localOnlyContentColumns, which this PR does not touch. MERGEABLE, no textual conflicts. It should still land after #997.

(b) Privacy and column classification

Clean, and it is the failure mode you asked about that makes it clean. The adapter registers no dataset of its own: it reuses ai_gateway_messages through ensureAiGatewayStorageContracts, so there is no new column classification to get wrong. The relevant property is that every row carries cwd - projectOpenCodeSnapshot returns undefined when session.directory is absent, in both lanes, so a content row without provenance cannot be written at all. That is the opposite of the recent defect (content, no localOnlyContentColumns, no cwd): withLocalOnlyVisibility and the export seam both withhold by cwd, so local-only rows are withheld rather than forwarded, and ignore never reaches storage. Verified by the local_only_export_withhold, source_optout_export_withhold, and hypignore_capture_drop smokes plus the adapter's own gates. Session-ignore is wired properly: the listener hosts the shared control route and advertises control_routes: ['ignore/session'], so the client-agnostic hyp session ignore reaches it via status discovery rather than a hardcoded list.

One note, not a blocker: /snapshot is an unauthenticated loopback endpoint that writes into the cache, so a local process (or a DNS-rebound browser page sending text/plain, since readJson does not check content-type) can inject fabricated rows. Poisoning only, not exfiltration, and the gateway has a comparable surface.

(c) Acceptance procedure

Present - opencode_cli_desktop_capture in docs/ACCEPTANCE.md, and it is a good one. Two gaps I fixed: it was not listed in CLAUDE.md's Smoke Test Model section or the release smoke battery (both added), and step 5's second import used --since '<same UTC timestamp>' --until '<same UTC timestamp>', a zero-width window that selects nothing and would report zero new rows without ever re-exporting the sessions the dedupe is being tested on. I also added a pass condition to step 4 requiring the assistant text to be the whole final answer, since finding 2 shows a truncated row there is a capture defect no later import can repair.

(d) LLP coverage

LLP 0306 is present, Accepted, thorough, and correctly forward-referenced from LLP 0045's Extended-by:. All 13 @ref LLP 0306#... annotations resolve to real anchors. I did not mint an LLP. Two things a human should decide: the endpoint-drift gap in finding 5, and whether §recovery-lane should record the message-grain rule my fix adds (it is the same principle the section already states, applied one level up, so I read it as realizing the decision rather than changing it - but it is a settled doc and that call is not mine).

Tests

  • npm test: 5097 pass, 3 fail. Identical 3 at the merge-base (storage.dataSourceForTable, hyparquet floor, purged rows stay purged) - the known stale-node_modules baseline. Delta zero.
  • npm run typecheck: clean.
  • Smokes, all green after the fixes: opencode_capture, cli_bundled_plugins_activated, walkthrough_picker_to_first_query, client_attach_idempotent, hypignore_capture_drop, local_only_export_withhold, source_optout_export_withhold, status_diagnostics, gateway_claude_capture, gateway_codex_capture, claude_telemetry_capture, otel_loopback_capture, local_parquet_export, package_bin_boot, daemon_foreground_start_stop, daemon_install_render, core_boot_noop.
  • New regression tests cover both headline defects and were confirmed to fail when the fix is reverted.

Verdict

Good design, a genuinely well-written acceptance procedure, and a solid privacy posture. But at 3a16446c the live lane recorded nothing at all, and any turn it did observe mid-stream was silently truncated - neither reachable by the hermetic smoke, which only ever posts snapshots we wrote ourselves. Both are fixed and verified against real OpenCode. Finding 5 remains open for a human.

philcunliffeand others added 2 commits August 25, 2026 00:08
# Conflicts:
#	hypaware-core/plugins-workspace/central/proto.md
#	hypaware-core/plugins-workspace/central/src/sink.js
#	llp/0014-sinks.spec.md
#	llp/0305-open-dataset-central-forwarding.decision.md
#	src/core/config/action_attach.js
#	test/plugins/central-forward-chunking.test.js
…#998)
Five review findings on the OpenCode CLI/Desktop capture PR.
F1 `hyp setup`'s attach lane and `hyp status`'s clients line resolved
clients through the AI gateway capability, whose getClient/listClients
filter to registrations with a gateway upstream. An endpoint-free client
(LLP 0306) is therefore invisible to both: a solo pick skipped the attach
block entirely, a mixed pick recorded the adapterless not-applicable
result meant for Claude Desktop (LLP 0115) while attaching nothing, and
status omitted the client. Both now read the intrinsic registry, which is
the superset, and hand no endpoint to an adapter that declares it needs
none. The CA wait is skipped when no gateway is active, since there is no
proxy CA to wait for.
F2 One unreadable session aborted the whole backfill run: the per-session
`opencode export` and its JSON.parse were unguarded inside the loop, so a
single throw dropped every remaining session (up to 1000). Warn and
continue, matching codex and openclaw.
F3 The listener's stop() called only server.close(), which waits on
outstanding sockets, so a connected OpenCode blocked `hyp daemon stop`.
Close idle and then all connections, like every peer listener.
F4 The unowned-plugin-file collision threw a plain Error, which the
reconciler classified as a retryable failure and re-attempted forever on a
condition only the user can clear. Mark it refused (LLP 0186).
F5 An errored assistant turn set a message-grain raw_frame that row
expansion can never reach, because every projected block carries its own
part frame and the block's wins. The error rides message attributes
instead, where it lands on a row.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Review round 2 of 2 — head b22af7fe — verdict: findings (5 actionable, all fixed)

Reviewed against merge-base a4c6350b. Round 1's findings were re-checked in the code, not taken from the claims: findings 1, 2, 3, 4, 6 and 7 are addressed at this head, with regression tests present for both headline defects. Finding 5 (endpoint drift) remains open and is carried below as the one item that needs a human.

The conflict resolution that landed in src/core/config/action_attach.js is correct. requiresEndpoint !== false in perform() and registration?.requiresEndpoint !== false in isCurrent() both degrade to the previous behaviour when ctx.clients is absent or the client is unregistered (undefined !== false, so the endpoint check still applies), and createClientRegistry normalises requiresEndpoint to a boolean, so the !== false / === false spellings cannot disagree. LLP 0306 does define {#endpoint-free-clients}; all seven added @ref LLP anchors resolve.

Findings and what was done

1. MAJOR — endpoint-free clients were invisible to hyp setup's attach lane and hyp status's client list.src/core/cli/walkthrough.js:1710,1729, src/core/commands/status.js:1099-1103. The gateway capability's isGatewayClient filter (ai-gateway/src/api.js:167-170) requires a string defaultUpstream, so it hides OpenCode, but both core consumers still read the gateway capability instead of the intrinsic registry. Proven by running the real modules: kernel.listClients['claude','opencode'], gateway.listClients['claude'], gateway.getClient('opencode')undefined. Consequences: a setup run picking only OpenCode skipped the attach block entirely; a mixed pick recorded { ok: true, noAdapter: true } (the adapterless branch meant for Claude Desktop, LLP 0115) and reported success while attaching nothing; hyp status omitted OpenCode. The daemon reconcile pass masked the first two, but --dry-run and any run that skips daemon install healed nothing.
FixedlistClientNames now reads ctx.clients first with the gateway capability as fallback; runPickerFinale takes a clients arg and gates on "any picked client resolves in the registry", with the adapterless path kept byte-identical. Covered by test/core/status-endpoint-free-clients.test.js, two new cases in test/core/walkthrough-attach-lane.test.js, and a new setup_attach_lane step in the opencode_capture smoke.

2. MAJOR — one bad session aborted the entire backfill run.opencode/src/backfill.js:96-97: runCommand(['export', id]) and JSON.parse were unguarded inside the per-session loop, so a session deleted mid-run, a permissions error, a renamed subcommand or a maxBuffer overflow threw out of the async generator and dropped every remaining session — up to 999 of them at MAX_SESSION_LIST = 1000.
Fixed — try/catch that warns opencode.backfill.session_read_failed with source_path, session_id, error_kind and continues, matching the codex and openclaw peers. Covered by a test with four sessions where one throws and one returns invalid JSON, asserting the two survivors project.

3. MINOR — stop() could hang on a keep-alive socket.opencode/src/listener.js:101: server.close() alone waits for outstanding sockets, and the plugin posts via fetch (undici keep-alive), so a running OpenCode blocked hyp daemon stop.
FixedcloseIdleConnections() / closeAllConnections() added, verbatim from the otel and claude peers. The regression test was checked to actually discriminate: with the two lines removed it fails at stop() did not return within 2s.

4. MINOR — the unowned-collision refusal was retried forever.opencode/src/attach.js:224 threw a plain Error, which reaches action_attach.js:180 as failed, so action_reconciler.js:249-286 rewrote the marker with attempts++ and logged an error on every reconcile pass, permanently, for a condition only the user can clear. LLP 0186 defines refused for exactly this.
Fixedthrow markActionRefused(new Error(...)), as the claude and openclaw peers already do; the existing rejection test now asserts isActionRefused(err).

5. MINOR — errored-turn detail was captured into a field that can never reach a row.opencode/src/projector.js:110 set message.raw_frame = { error }, but row expansion prefers the block's frame (message_projector.js:1149-1151) and every block carries raw_frame unconditionally, so an aborted or failed assistant turn was persisted with its error silently discarded.
Fixed by surfacing rather than deleting — the error now merges into message.attributes, which message_projector.js:1148 writes to the row unconditionally. The trade-off: the error replicates across every part of the message rather than riding only the carrier, which is defensible because an abort is a message-level fact, unlike per-response token counts.

Open — needs a human decision (carried from round 1, unchanged)

Endpoint drift for endpoint-free clients.src/core/config/action_attach.js:298-311, opencode/src/index.js:59. Attach bakes http://127.0.0.1:${opencodeListenPort(ctx.config)} into the file it writes, but isCurrent() now skips the endpoint comparison for requiresEndpoint: false and perform() records detail = {}. Change opencode.listen_port and the reconciler sees an unchanged assets_key, never re-attaches, and the installed plugin keeps POSTing at the old port into the asset's bare catch {} — while hyp status reports attached: true because the ownership marker is present. The endpoint-free path dropped LLP 0086's protection but kept the hazard.

This was left deliberately unfixed: it is documented, not accidental (action_attach.js:289-292 plus LLP 0306 §endpoint-free-clients), and the fix — having the marker record the adapter's own endpoint — would extend what LLP 0306 settled rather than correct a slip, which is a design call and not a review-fix. Scope: manual hyp client attach opencode always rewrites and the default port is fixed, so only the reconciled path is exposed.

Nits, not fixed and not blocking

  • src/core/daemon/runtime.js:1073clients ??= gateway is dead code (activation.js:99 always defines boot.runtime.clients); it reads as a live fallback that is not one.
  • The ownership marker is spelled HYPWARE_OPENCODE_PLUGIN, missing the A, consistently across hypaware.plugin.json, assets/hypaware.js:1, attach.js:192, the smoke and docs/ACCEPTANCE.md. It functions, but it is user-visible, detach matches it exactly, and a post-ship rename needs a compat path — cheapest to decide before merge.
  • ai-gateway/src/index.js:65,75,85 — a braceless if wrapping a 9-line call. Separately, ai-gateway and opencode now both claim the three session * commands under symmetric first-writer-wins guards; that co-ownership works but LLP 0306 does not mention it.
  • listener.js:216readJson accepts any content-type and the server does no Origin/Host check, so a browser page can POST text/plain JSON to 127.0.0.1:4320/snapshot and inject fabricated rows (poisoning, not exfiltration). Repo-consistent with the claude telemetry listener, so not a gap this PR introduces.

For whoever runs the acceptance procedure (not a code finding)

assets/hypaware.js:71-79 debounces every wake-up by 25 ms and then fires the POST with nothing awaiting it. docs/ACCEPTANCE.md step 3 uses opencode run "...", a short-lived process that can exit inside that window — in which case the turn is recovered only by the export lane and lands with entrypoint = 'unknown', failing step 4's entrypoint = 'cli' pass condition. Round 1 verified the live lane against a real session.created, which does not cover process exit. This may be nothing if OpenCode drains plugin work before exit, but it is the one live-lane claim no hermetic test can reach.

Verification

Fixes are at head ccad09ec, verified present in the committed tree at each named symbol. npm test 5202 tests / 5201 pass / 0 fail / 1 skipped (baseline 5194/5193/0/1, +8 new cases); npm run typecheck clean; smokes opencode_capture, walkthrough_picker_to_first_query, client_attach_idempotent and status_diagnostics all green.

Round 2 of 2 is now recorded, so the next reconcile tick takes this PR to triage to classify the one open item above as blocker or preference.

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Triage at head ccad09ec: mergeable, residual findings deferred to #1000

The review-round cap (2 of 2) is exhausted with residual findings open, so triage made LLP 0017's call on each, verified against the code at this head:

  • Endpoint drift for endpoint-free clients: non-blocking. The listener has no port fallback (src/core/otlp/server.js:163), so drift is reachable only through a deliberate edit of the defaulted opencode.listen_port; the failure degrades one capture lane and is recoverable (hyp client attach opencode, or backfill); and the behaviour is a documented Accepted-LLP decision (LLP 0306 #endpoint-free-clients) whose fix is a design extension, not a review patch. Deferred as the headline item of Follow-up: deferred review findings from PR #998 #1000.
  • Snapshot listener content-type/Origin laxity: non-blocking, with a correction. The claude OTLP ingest peer actually rejects non-application/json (415, src/core/otlp/server.js:87), so /snapshot is laxer than that peer; but the pre-existing /_hypaware/ignore/session control handler (src/core/control/session_ignore.js) already parses any content-type on an unauthenticated loopback mutation route, so browser drive-by mutation of daemon state is master's existing posture, not a new class this PR introduces. Deferred to a repo-wide loopback-listener hardening item in Follow-up: deferred review findings from PR #998 #1000.
  • HYPWARE_OPENCODE_PLUGIN marker misspelling: preference; versioned marker gives a rename a natural v2 compat path. Deferred.
  • clients ??= gateway dead code (src/core/daemon/runtime.js:1073): preference. Deferred.
  • Braceless if / undocumented session * co-ownership in ai-gateway: preference; behaviour is fine under the first-writer-wins guards. Deferred.

All residual findings are non-blocking, so this PR can ship safely at ccad09ecd49f4874124f02b24a3dabf971c67ec8. Every deferred item is enumerated with file:line and rationale in #1000.

@philcunliffephilcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 25, 2026
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Ship risk: high

Head:ccad09ecd49f4874124f02b24a3dabf971c67ec8
Base:master (merge-base a4c6350b3deb551739a79c7192d5a226cf9758cd)

Shadow gate, observe mode: this record is prospective. The PR is held, not merged.

What changed

  • A new kernel-owned client registry becomes the primary attach seam for every
    client, not just the new one. src/core/registry/clients.js:15 creates it,
    src/core/runtime/activation.js:99 puts it on every KernelRuntime, and
    hypaware-core/plugins-workspace/ai-gateway/src/api.js:46 makes the gateway
    capability a filtered view over it (isGatewayClient, api.js:170, keeps only
    registrations carrying defaultUpstream). claude/codex/openclaw now flow
    through a registry they did not previously touch.
  • src/core/daemon/runtime.js:1063 changes the daemon reconcile seam from "the
    gateway capability, or nothing" to "the intrinsic registry, always". The attach
    handler's endpoint rules become conditional on requiresEndpoint
    (src/core/config/action_attach.js:154, :305).
  • src/core/commands/clients.js:165 makes the ai-gateway capability optional for
    hyp attach/detach, merging two registries (mergeClientRegistries, :1824).
    src/core/cli/walkthrough.js:1719 and src/core/commands/status.js:1102 switch
    their client resolution to the intrinsic registry.
  • New public-contract surface in hypaware-plugin-kernel-types.d.ts:
    ClientRegistration/ClientRegistry/ClientAttachContext interfaces,
    PluginActivationContext.clients as a required member (:650),
    CommandRunContext.clients optional (:1026), and the on-disk manifest probe
    union extended with 'managed_file' (:278) plus marker_text (:292).
  • New @hypaware/opencode plugin: a loopback HTTP listener on 127.0.0.1:4320
    (listener.js:19, :71), a scheduled opencode export backfill provider
    (backfill.js:153), and an attach that writes an executable JS file into
    ~/.config/opencode/plugins/hypaware.js which OpenCode loads into its own
    process (attach.js:29, assets/hypaware.js).
  • Shared, cross-client edits to the normalized-conversation projector:
    message_projector.js:1126 (part_id may now come from the block),
    :1136 (tool_args now also populated for tool_result), :1149
    (raw_frame may now come from the block), :1354 (tool_result tool-name
    resolution). These run for every existing gateway client.

Surface

  • Files accounted for: 49. Kernel/attach seam (8): registry/clients.js (new),
    runtime/activation.js, runtime/bundled.js, cli/dispatch.js,
    cli/walkthrough.js, cli/wizard/index.js, config/action_attach.js,
    commands/clients.js. Disk/probe contract (4): config/client_detach_disk.js,
    daemon/client_settings_path.js, daemon/status.js, daemon/runtime.js.
    Type contracts (4): hypaware-plugin-kernel-types.d.ts, cli/types.d.ts,
    config/types.d.ts, runtime/types.d.ts. Shared gateway plugin (4): api.js,
    index.js, message_projector.js, storage_contracts.js (new). New plugin (8):
    opencode/** + assets/hypaware.js. Tests (13) and smokes (3) — the five
    modified pre-existing tests are honest expansions for the added client
    (attach-all-notes, pick, ask, daemon-reconcile, sink-materialize), not
    weakened assertions; none mask a regression. Docs/LLP (5): AGENTS.md,
    docs/ACCEPTANCE.md, llp/0045, llp/0306 (new).
  • Callers/contracts/config: the gateway capability hypaware.ai-gateway@2.0.0
    is a published capability consumed by walkthrough.js, status.js,
    clients.js, daemon/runtime.js; all four were rewired. New user config key
    opencode.listen_port (opencode/src/config.js:22). New on-disk contract: the
    managed_file probe format, read by daemon/status.js:2410 and undone by
    client_detach_disk.js:151. Package contract verified: npm pack --dry-run
    ships all 9 opencode files including assets/hypaware.js.
  • Concurrency/lifecycle: yes. A new long-lived daemon-owned HTTP server with
    its own stop() that force-closes keep-alive sockets (listener.js:96-106), a
    25ms debounce timer map inside the injected client plugin
    (assets/hypaware.js:74), a scheduled execFile('opencode', …) sweep with a
    32MB buffer and no timeout (backfill.js:153), and a mutable
    ignoredSessions Set shared between the live and sweep lanes
    (opencode/src/index.js:36).
  • Sensitive surfaces: four. (1) Published plugin/kernel type contract, with a
    required new PluginActivationContext.clients member. (2) Background lifecycle
    management (new listener + sweep + daemon reconcile seam). (3) Writes and deletes
    an executable file under $HOME that a third-party process loads. (4) Personal
    data: a new capture lane for AI prompt/response content into
    ai_gateway_messages.

Critical safety fact

Routing every client through the new kernel registry does not change how the
pre-existing gateway-backed clients attach: claude is still reachable through
the hypaware.ai-gateway@2.0.0 capability with defaultUpstream intact and
requiresEndpoint === true; the reconciler still refuses its attach without a
proven-bound gateway endpoint, still hands the adapter that endpoint verbatim,
and still re-attaches on endpoint drift and on proxy-era mode drift — and the
endpoint exemption reaches opencode only.

Evidence level: 5 (reproduced through the real CLI and a real kernel boot)

Proof A — focused, real current-head code, 16 assertions, fails loudly:

PROOF_REPO=$WT node $GIT_COMMON_DIR/ship-risk/pr-998/ccad09ec…/proof.mjs → exit 0

The script boots the real kernel via loadManifestsresolveDependencies
activatePlugins over the real @hypaware/ai-gateway, @hypaware/claude and
@hypaware/opencode plugin directories, then drives the real
createAttachHandler():

PASS claude present in intrinsic registry
PASS claude STILL present via ai-gateway capability getClient
PASS claude requiresEndpoint === true :: true
PASS claude defaultUpstream preserved through the new registry copy :: anthropic
PASS claude listed by gateway.listClients() :: claude
PASS opencode present in intrinsic registry
PASS opencode requiresEndpoint === false :: false
PASS opencode hidden from the gateway capability (contract unchanged for gateway consumers)
PASS claude perform() with no endpoint still fails with the pre-existing reason :: {"status":"failed","reason":"attach action missing gateway endpoint"}
PASS opencode perform() is not blocked by the missing-endpoint guard :: {"status":"done","detail":{"settings_path":".../.config/opencode/plugins/hypaware.js"}}
PASS claude adapter still receives the live gateway endpoint verbatim :: {"endpoint":"http://127.0.0.1:8787","json":true}
PASS claude marker at a stale endpoint is NOT current (LLP 0086 re-attach-on-drift intact) :: false
PASS claude marker with no live endpoint is left current (unchanged) :: true
PASS claude proxy-era marker still stale at an unchanged endpoint (LLP 0262 migration intact) :: false
PASS opencode marker is exempt from endpoint drift (the deferred #1000 gap, reproduced) :: true
PASS with no ctx.clients the endpoint rule still applies (fail-safe default) :: false
ALL PROOF ASSERTIONS PASSED

Proof B — reproduced through the packaged CLI (temp HYP_HOME + temp HOME,
config enabling ai-gateway + claude + opencode):

node bin/hypaware.js attach --client claude --json

{"status":"ok","action":"attach","client":"claude","dry_run":false,
"settings_path":".../.claude/settings.json","changed":true,"port":4319,
"mode":"otel","telemetry_port":4319,"spool_dir":".../spool/claude-bodies"}

Unchanged claude shape (port + mode: otel + spool dir). Follow-ups in the same
temp home: hyp status --json reports claudeattached:true version 2.0.0 port 4319 mode otel; the seeded user keys env.ANTHROPIC_API_KEY and
permissions.allow survive attach and hyp detach --client claude restores the
file to its exact pre-attach content; hyp attach --client all attaches claude
and opencode in one pass; and in an opencode-only install (no ai-gateway
plugin at all) hyp attach --client opencode succeeds while hyp attach --client claude still fails with the pre-existing adapter_not_enabled message rather
than crashing on the now-absent capability.

Confirmed risks

  • Silent capture loss on opencode.listen_port drift, with hyp status still
    reporting success
    (the headline deferral to Follow-up: deferred review findings from PR #998 #1000). Reproduced: after editing
    the config to listen_port: 4999, the installed plugin still holds
    HYPWARE_ENDPOINT = 'http://127.0.0.1:4320' and hyp status --json still
    reports {"name":"opencode",…,"attached":true}. The reconciler will not
    re-attach because isCurrent skips the endpoint key for endpoint-free clients
    (action_attach.js:305-311, asserted above), and the injected plugin swallows
    the resulting POST failure in a bare catch {}
    (assets/hypaware.js:68). Likelihood: low (requires a deliberate user edit).
    Consequence: medium and silent. Detection today: none; recovery is a manual
    hyp attach opencode or the export sweep.
  • A new required member on a published contract.PluginActivationContext.clients
    is non-optional (hypaware-plugin-kernel-types.d.ts:650). Any out-of-tree host
    or test harness constructing that context breaks at typecheck. In-tree this was
    handled (test/core/sink-materialize.test.js:137), but the blast radius is
    external.
  • execFile('opencode', …) has no timeout (backfill.js:153). A hung OpenCode
    binary stalls that backfill pass indefinitely; maxBuffer is bounded (32MB) but
    wall time is not.
  • The new capture lane has never run against a real OpenCode. This PR itself
    adds a mandatory manual gate, opencode_cli_desktop_capture
    (docs/ACCEPTANCE.md, AGENTS.md:132), requiring OpenCode CLI and Desktop on
    a real host. Per CLAUDE.md, acceptance procedures "are never simulated with
    fixtures", and the hermetic opencode_capture smoke POSTs a fixture this PR
    wrote — so it agrees with itself regardless of what OpenCode actually emits.
    No machine in this assessment can close that gap.

Cleared

  • Existing gateway clients regress on the attach path — cleared at evidence
    level 5 by Proofs A and B above (claude reachable, endpoint verbatim, drift
    re-attach, proxy-era migration, --client all, detach round trip, gateway-absent
    install).
  • Fail-safe default when ctx.clients is absent — cleared: registration?.requiresEndpoint !== false
    evaluates true for undefined, so a host that predates the registry keeps the
    endpoint rules. Asserted (action_attach.js:305).
  • Dataset-ownership race: ensureAiGatewayStorageContracts registers
    ai_gateway_messages first-come, and the gateway's state (which carries the
    enrichers used by upgradeFallbackRows, dataset.js:425) would be lost if
    opencode won. Cleared: dep_graph.js:246 sorts independent plugins
    alphabetically, so @hypaware/ai-gateway always activates before
    @hypaware/opencode; when the gateway is absent there are no enrichers to lose.
  • Packaging: npm pack --dry-run ships all 9 opencode files including the
    injected assets/hypaware.js template. A missing asset would have been a
    runtime attach failure in the tarball only.
  • Backfill command injection: execFile with an args array, no shell
    (backfill.js:153).
  • Detach destroying a user's own file at the managed path: reproduced —
    overwriting the plugin file with foreign content makes hyp detach --client opencode return {"changed":false,"warning":"managed file ownership marker is missing; leaving file in place"} and the file survives byte-for-byte; re-attach
    refuses with a marked terminal refusal rather than clobbering it.
  • Shared projector edits regressing existing clients: part_id, raw_frame,
    and tool_result tool-name changes are all guarded fallbacks that only fire when
    the block carries the new field, which no gateway client emits. Supporting
    evidence only (full suite green); not independently proven per-client.
  • Suite state at this exact head: npm test → exit 0, 5202 tests / 5201 pass /
    0 fail / 1 skipped. npm run typecheck → exit 0. Smokes opencode_capture,
    client_attach_idempotent, walkthrough_picker_to_first_query,
    status_diagnostics, cli_bundled_plugins_activated, gateway_claude_capture
    → all exit 0.

Rationale

Reach is high: this is not a self-contained plugin addition. It moves the
client seam for every existing client into a new kernel registry, changes the
daemon reconcile seam, makes a published capability a filtered view, adds a
required member to the published plugin contract, and extends a serialized
on-disk manifest format. Consequence is high by sensitive surface: background
lifecycle management (a new daemon-owned listener plus an untimed subprocess
sweep), a write/delete of an executable file under $HOME that a third-party
process loads and runs, and a new personal-data capture lane. The rubric puts any
of those at high on its own, and the diff is emphatically behavioral.

Evidence is strong — the critical safety fact reached level 5, and the four
plausible collateral failures (registry ordering, packaging, foreign-file
destruction, gateway-absent CLI) were each investigated and cleared by running
code — but per the rubric evidence can clear a suspected path and cannot downgrade
an inherently high-consequence surface. The one gap no local evidence can close is
the one this PR names itself: the mandatory opencode_cli_desktop_capture
acceptance run against a real OpenCode CLI and Desktop.

Not automerge-eligible. The correct disposition is a human running the new
acceptance procedure before this lands, which is exactly what high encodes.
Nothing here suggests the code is wrong; it says the surface is not one a machine
should land unattended.

Before merge

  • Run opencode_cli_desktop_capture from docs/ACCEPTANCE.md on a host with real
    OpenCode CLI and Desktop, and record the observed opencode --version in the
    release notes. This is the only check that can catch the silent failure mode
    (null columns / zero rows, not an error) if the injected plugin's assumptions
    about OpenCode's event and SDK shape are wrong.
  • Cheapest durable fix for the Follow-up: deferred review findings from PR #998 #1000 headline gap: include the configured port in
    the opencode attach marker detail and compare it in isCurrent, so a
    listen_port edit re-attaches instead of leaving attached: true lying. A
    hyp status capture-health line that compares the port baked into the installed
    plugin file against the live listener would make the failure visible either way.
  • Add a timeout to execFileAsync('opencode', …) in backfill.js:153.

@philcunliffe
philcunliffe added this pull request to the merge queueAug 25, 2026
Merged via the queue into master with commit 56f2dfeAug 25, 2026
8 checks passed
@philcunliffe
philcunliffe deleted the codex/opencode-cli-desktop-capture branch August 25, 2026 03:52
philcunliffe pushed a commit that referenced this pull request Aug 25, 2026
Two conflicting hunks in src/core/config/action_attach.js, both in
isCurrent(). #998 made the endpoint freshness key conditional on the
client's requiresEndpoint flag (LLP 0306); this branch added a fourth,
adapter-owned freshness key (LLP 0308). Both hold: the endpoint check
keeps master's requiresEndpoint guard, and the adapter key is still read
before attach() in perform() and still checked last in isCurrent(), after
the three pure keys.
readAttachKey() in isCurrent() now reuses the `registration` binding
master introduced instead of resolving getClient(client) a second time.
attachKey?() landed on master's new ClientRegistration base, which
AiGatewayClientRegistration extends, so codex keeps the hook and the new
opencode client correctly declares none.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
philcunliffe pushed a commit that referenced this pull request Aug 25, 2026
Conflict: the AGENTS.md release-checklist smoke battery. master (#998)
added `opencode_capture` to the list; this branch rolled every entry to
the canonical `hyp dev smoke` spelling. Kept both: master's full list,
`opencode_capture` in its position included, on the `hyp dev smoke`
spelling. No `hyp smoke ` occurrence remains in the file.
hypaware-plugin-kernel-types.d.ts merged cleanly. This branch's addition
there is a `CommandRegistry.register` docstring only, so nothing in the
contract's shape moved.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:adoptForeign PR adopted into neutral's reconcile scopeneutral:adoptedAdoption completion record: merged while carrying neutral:adopt (LLP 0031)neutral:approvedneutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@philcunliffe