feat(opencode): full driver — typed expansion, SSE observed state, native delivery - #325
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cebddb797a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
cebddb7 to
b2df2fd
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b2df2fdcc1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
b2df2fd to
cd5b009
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cd5b00923d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
cd5b009 to
1278d64
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1278d64e59
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
1278d64 to
95a041f
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 95a041f94d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
95a041f to
26ebfff
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 26ebfff096
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
26ebfff to
f8392cc
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f8392cc483
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
f8392cc to
8ffc60e
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8ffc60e269
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
8ffc60e to
52fe79e
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 52fe79e1e5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| _ => { | ||
| if self.busy.contains_key(&session_id) { | ||
| self.poisoned = true; |
There was a problem hiding this comment.
Invalidate idle evidence for every unknown session status
When a successful seed has established idle and a newly created or previously untracked session then emits a future status such as paused, this condition does not poison the projection because the session is absent from busy; the wrapper therefore continues heartbeating a definite idle state even though that session's activity is unknown. The new untracked-session branch is fresh evidence beyond the earlier tracked-busy case. Invalidate existing level evidence whenever any session moves to an unreadable status, not only when that session was already tracked as busy.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in fca92a5 on schickling/hs-07-opencode: an unrecognized session.status word now poisons the projection for ANY session -- tracked or untracked -- since an untracked session in an unreadable state makes standing idle evidence a fabrication; withholding holds until a fresh level seed rebuilds the picture (sticky terminals still outrank the poison).
Posted on behalf of @schickling
| field | value |
|---|---|
agent_identity |
unknown |
agent_persona |
generalist |
agent_supervisor |
unavailable |
agent_tool |
OMP |
agent_tool_version |
18.0.3 |
agent_runtime |
OMP 18.0.3 |
tooling_profile |
dotfiles@f33cd9c-dirty |
| ) | ||
| .with_ownership(session, seq) | ||
| }; | ||
| let mut child = spawn_provider(&argv, &password)?; |
There was a problem hiding this comment.
Record a terminal launch error when spawning fails
If the OpenCode executable is missing, loses execute permission, or otherwise fails in spawn_provider, this ? returns after harness_state::claim has already replaced the prior record, but no real terminal observation is written. The durable record is consequently left as the claim placeholder ended (superseded) rather than launch-error, misclassifying the failed launch; handle the spawn error and write through writer before returning it.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in fca92a5 on schickling/hs-07-opencode: a failed spawn_provider no longer returns through ? past the claim; the wrapper writes an honest ended / launch-error / exit unknown terminal through its adopted ownership before propagating, matching the Claude and pi launch paths.
Posted on behalf of @schickling
| field | value |
|---|---|
agent_identity |
unknown |
agent_persona |
generalist |
agent_supervisor |
unavailable |
agent_tool |
OMP |
agent_tool_version |
18.0.3 |
agent_runtime |
OMP 18.0.3 |
tooling_profile |
dotfiles@f33cd9c-dirty |
| std::fs::write(&temp, serde_json::to_vec(value)?)?; | ||
| if let Err(error) = std::fs::rename(&temp, path) { |
There was a problem hiding this comment.
Fsync the attempted receipt before sending
When the host crashes after this rename and after prompt_async is accepted, but before the unsynced receipt reaches stable storage, the OpenCode message can survive in its SQLite store while delivery-state.json disappears after reboot. The pump then posts the same stable messageID without first reconciling it, and the measured OpenCode behavior appends the text parts again. Since the transport relies on Attempted being durable before transport, sync the temporary file before renaming and sync the parent directory before allowing the POST.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in fca92a5 on schickling/hs-07-opencode: atomic_json fsyncs the temp file before the rename and syncs the parent directory after it, so once delivery proceeds to prompt_async the Attempted receipt survives a host crash and the pump cannot re-POST duplicate parts.
Posted on behalf of @schickling
| field | value |
|---|---|
agent_identity |
unknown |
agent_persona |
generalist |
agent_supervisor |
unavailable |
agent_tool |
OMP |
agent_tool_version |
18.0.3 |
agent_runtime |
OMP 18.0.3 |
tooling_profile |
dotfiles@f33cd9c-dirty |
…ved state, native delivery OpenCode's TUI is also a server, so the driver needs no screen scraping: the opencode-session wrapper allocates a loopback port and per-seat password, owns the presence lease and the harness-state writer, projects the /event SSE stream (busy/idle/retry, id-matched permission and question edges, session errors) with evidence-gated heartbeats, and delivers inbox messages over POST prompt_async with a stable caller messageID whose only accepted receipt is the message read back durably — never the /tui/* endpoints, which acknowledge input with no TUI attached. Delivery is fail-closed behind SUPPORTED_OPENCODE_VERSIONS plus a live /doc OpenAPI subset check that names whatever went missing. Includes the scoped watch_delivery_inputs watcher (inbox subtree + status only) pending merge unification with the codex slice, the measured 2026-08-23 OpenCode surface experiment record, and the spec.md producer section rewritten to the implemented design. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ID, SSE needs HTTP/1.0 Live capture on 1.18.19: permission.asked/question.asked carry properties.id but their replied/rejected exits carry properties.requestID, so the shipped id extraction never released blockedOn:human after a real grant; and GET /event over HTTP/1.1 is chunk-encoded, which the line-oriented SSE reader cannot parse safely, while HTTP/1.0 streams raw. Verbatim captured event pairs are now fixture tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Config-file permission settings ask headless (the PATCH path was the earlier failure); verbatim captured pairs recorded, the requestID and HTTP/1.0 corrections documented, and prompt_async's repeated-messageID parts-append behavior measured. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-only retries, runtime-id sessions Review-pass fixes: evidence (and with it heartbeats) turns on only after a successful level seed, with the seed retried while the SSE connection lives — a transiently failed seed no longer resumed heartbeats on whatever the disk last said; the seed also recovers permission asks already pending at (re)connect under their own ids so blockedOn survives an SSE drop and the ordinary id-matched exit still releases it (pending questions have no verified listing endpoint on 1.18.19 — stated in code); an indeterminate delivery read-back retries the read-back and never re-POSTs, because a same-messageID re-POST appends duplicate parts (measured); and ptySession records the wrapper's runtime ID. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s, and driver-block discovery Pending questions recover across an SSE reconnect from their own listing endpoint (measured on 1.18.19) exactly like permissions; blocked observations carry the machine-readable ask kind; the restarted wrapper opens a fresh transition; and a lone driver block of any provider — opencode and the silently-missing pi included — is a spec candidate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ly recognized level evidence The /doc subset names the question exit arms and both pending-ask listings (a release renaming an exit would otherwise hold blockedOn forever); the reconnect seed fails — keeping heartbeats off and retrying — unless the status level AND both ask listings succeed; and an unrecognized session.status word is no longer level evidence, so a future word cannot prove idle on a quiet server. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d status words, and keep real stop exits A pending delivery whose session settled before the observer connected recovers its binding from the session listing (idle sessions are invisible to events and /session/status) and retries until one exists; the reconnect seed matches exactly busy/retry and fails closed on an unknown word; the stop path rewrites its escalation-cover record with the exit the grace-window reap actually observed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ver re-exports Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s nothing Found by the pre-submit self-review: the written claim sat after spawn_provider, so a claim failure error-aborted the launch around a running child. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…from the listing The reconnect seed builds a fresh machine and swaps it in only after every level read validates: a mid-seed failure leaves no half-seeded asks, and a successful re-seed clears stale busy/blocked entries whose exits passed while the stream was down — the level surface at seed time is the whole truth. The seed no longer touches delivery targeting at all: status-map iteration order is not recency, and targets resolve only through the session listing's newest entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d level evidence The SSE socket carries a read timeout of at least twice the measured heartbeat cadence, so a stalled connection surfaces as a disconnect (evidence off, reconnect and reseed) instead of keeping evidence alive forever; a /session/status response that is not the documented object shape fails the seed rather than fabricating definite idle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found by the cycle-7 self-review's error-arm sweep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ord on a tracked session poisons the projection, and an unreadable pending entry fails the seed Three honesty holes in the projection: - An SSE drop now marks the writer interrupted, so the first post-reseed observation opens a fresh transition instead of claiming continuity across an interval nobody observed. - A future status word on a session the machine tracks as busy poisons the whole projection (observations withheld, evidence dropped, immediate reseed): that busy entry could never be trusted to clear. - A pending-ask listing entry without a readable id fails the seed instead of being silently skipped: seeding around it would restore evidence on a picture that drops a human block. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ended does not depend on the busy map an unknown status word made untrustworthy; withholding it while forcing the reseed would silently lose the terminal to the fresh machine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e record, and the receipt survives a crash An unrecognized session.status word poisoned only sessions tracked busy; an untracked — possibly newly created — session in a state this version cannot read left definite idle heartbeating on top of unknown activity. Any unreadable status word now withholds the projection until a level seed rebuilds it. A failed spawn_provider returned through ? after the claim had already replaced the prior record, leaving the exitless ended (superseded) placeholder as a false takeover; the wrapper now writes an honest ended/launch-error terminal before returning. The Attempted receipt was renamed without fsync, so a crash between the rename and prompt_async acceptance could lose it and re-POST duplicate parts. The temp file is synced before the rename and the directory entry afterwards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> agent-identity: unknown agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.0.3 agent-runtime: OMP 18.0.3 tooling-profile: dotfiles@f33cd9c-dirty
52fe79e to
fca92a5
Compare
The full OpenCode driver — the fourth harness at parity, per the measured surface research (v1.18.19: the TUI is a server; captures reproduced in the shipped experiment record).
Typed driver:
Driver::OpenCode+expand_opencode+st2 driver opencode-session; expansion stays pure (the wrapper allocates the port at runtime).Wrapper (
src/opencode_session.rs): port + per-seatOPENCODE_SERVER_PASSWORDallocation, presence lease, SSE/eventreader with reconnect, and a pureEventMachine: any busy session ⇒ active (dedicated seat aggregates);permission.asked/question.asked⇒blockedOn: humanwith id-matched exits on the matching replied/rejected event; ProviderAuthError ⇒ ended; other errors ⇒ idle with reason; idle needs positive/session/statusevidence (the endpoint omits idle sessions). Disconnect stops heartbeats; the terminal record lands before SIGKILL escalation.Native delivery (replaces screen-scraped DING for driver seats): Codex-style durable FIFO —
Attemptedpersisted before transport,POST /session/{id}/prompt_asyncwith a stable callermessageID, receipt only by reading the message back; stale attempts reconcile by read-back before any resend./tui/*endpoints are never a transport (they returntruewith no TUI attached — measured).Fail-closed gates:
SUPPORTED_OPENCODE_VERSIONS+ a live/docOpenAPI subset check over exactly the arms st2 depends on; gate failure runs presence-only.Docs:
.experiments/2026-08-23-opencode-surface.md+ spec section updated to the implemented design. Open: a livepermission.askedcapture is still owed (DQ-H6).Cycle-2: pending questions re-seed across SSE reconnects from their own measured listing endpoint; blocked observations carry ask kinds (permission/question); a lone
[opencode](or[pi]) driver block is now a spec candidate; the restarted wrapper opens a fresh transition.Cycle-3: the reconnect seed is atomic (status level AND both pending-ask listings must succeed, else evidence stays off and it retries); an unrecognized session.status word is no longer level evidence; the
/docgate names every consumed arm including the question exit events and both pending listings.Cycle-4: pre-settled sessions are recovered from the session listing for pending deliveries; the status seed trusts exactly busy/retry/idle and fails closed on unknown words; the stop path records the grace-window reap's real exit over its escalation cover.
Cycle-8: an SSE disconnect marks the observation stream interrupted (no continuity claim across the outage); an unknown status word on a tracked-busy session poisons the projection (observations withheld, evidence dropped, immediate reseed — while a sticky terminal still outranks the poison); and a pending-listing entry without a readable id fails the whole seed instead of being skipped.
Part of the #268 stack (#319→#326).
🤖 Generated with Claude Code
Refinement pass (live captures + review): the DQ-H6 capture landed both blocked pairs live and corrected the producer twice — exit events spell the ask id
requestID(the schema-derivedid-only extraction would have heldblockedOn: humanforever after a real grant; verbatim captured pairs are now fixture tests), andGET /eventis chunk-encoded over HTTP/1.1, so the SSE reader requests HTTP/1.0, which the server streams raw. Review fixes: evidence (and heartbeats) turn on only after a successful level seed, retried while the connection lives; the seed re-enters permission asks already pending at (re)connect under their ids, soblockedOnsurvives an SSE drop; an indeterminate delivery read-back retries the read-back and never re-POSTs (measured: a same-messageID re-POST appends duplicate parts); the record-write volume defect the smoke measured (679 writes/221 s) is closed by the envelope's restatement guard; andptySessionrecords the wrapper's runtime ID.