Skip to content

Commit 802fe21

Browse files
schicklingclaudeschickling-assistant
authored
feat(opencode): full driver — typed expansion, SSE observed state, native delivery (#325)
* feat(opencode): full driver — typed expansion, session wrapper, observed 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> * fix(opencode): match the measured wire — replied events carry requestID, 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> * docs(vrs): resolve DQ-H6 with the live blocked-pair capture 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> * fix(opencode): seed-gated evidence, reconnect ask recovery, read-back-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> * fix(opencode): question re-seeding, session-boundary starts, ask kinds, 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> * fix(opencode): gate every consumed arm, seed atomically, and count only 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> * fix(opencode): recover pre-settled delivery targets, trust only pinned 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> * fix(opencode,agent-spec): written ownership claim and the missing driver re-exports Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(opencode): claim before the provider spawns — a failed claim leaks 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> * fix(opencode): atomic whole-truth seeding, and delivery targets only 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> * fix(opencode): a silence horizon on the stream, and only object-shaped 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> * fix(opencode): a failed liveness check ends the record honestly Found by the cycle-7 self-review's error-arm sweep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(opencode): a disconnect breaks continuity, an unreadable status word 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> * fix(opencode): a sticky terminal outranks the poison 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> * fix(opencode): every unreadable status poisons, spawn failures end the 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 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com>
1 parent 7d6a111 commit 802fe21

12 files changed

Lines changed: 2421 additions & 23 deletions

File tree

crates/agent-spec/src/kdl_format.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
//! ignored.
99
1010
use crate::declared::{DeclaredDocument, DeclaredNode, DeclaredValue};
11-
use crate::spec::{ClaudeDriver, CodexDriver, PiDriver, RawResource, RawRestart, RawSpec, RawTask};
11+
use crate::spec::{
12+
ClaudeDriver, CodexDriver, OpenCodeDriver, PiDriver, RawResource, RawRestart, RawSpec, RawTask,
13+
};
1214

1315
/// Lower an already parsed declaration document into the runner's raw representation.
1416
pub(crate) fn lower_declared_document(document: &DeclaredDocument) -> anyhow::Result<Vec<RawSpec>> {
@@ -164,6 +166,13 @@ fn agent_node_to_raw(node: &DeclaredNode) -> anyhow::Result<RawSpec> {
164166
);
165167
raw.driver.pi = Some(pi_driver_node_to_raw(child)?);
166168
}
169+
"opencode" => {
170+
anyhow::ensure!(
171+
raw.driver.opencode.is_none(),
172+
"agent declares `opencode` more than once"
173+
);
174+
raw.driver.opencode = Some(opencode_driver_node_to_raw(child)?);
175+
}
167176
"env" => {}
168177
"pty" => {
169178
if let Some(name) = arg_string(child) {
@@ -343,6 +352,19 @@ fn pi_driver_node_to_raw(node: &DeclaredNode) -> anyhow::Result<PiDriver> {
343352
})
344353
}
345354

355+
fn opencode_driver_node_to_raw(node: &DeclaredNode) -> anyhow::Result<OpenCodeDriver> {
356+
let (model, effort, _, prompt, args) = common_driver_fields(node, "opencode", false)?;
357+
anyhow::ensure!(
358+
effort.is_none(),
359+
"agent `opencode` has unsupported field `effort` (OpenCode has no effort axis)"
360+
);
361+
Ok(OpenCodeDriver {
362+
model,
363+
prompt,
364+
args,
365+
})
366+
}
367+
346368
fn parse_presentation(
347369
node: &DeclaredNode,
348370
field: &str,

crates/agent-spec/src/lib.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ pub use discovery::{
4444
};
4545
pub use spec::{
4646
AgentDesiredState, AgentSpec, ClaudeDriver, CodexDriver, DeliveryTransport, Driver, JobType,
47-
PiDriver, Resource, Restart, RestartMode, STREAM_TASK_PREFIX, Stream, StreamLaunch, Task,
48-
TaskKind, TaskLifecycle, parse_duration, stream_name_of_task, validate_desired_state_reason,
47+
OpenCodeDriver, PiDriver, Resource, Restart, RestartMode, STREAM_TASK_PREFIX, Stream,
48+
StreamLaunch, Task, TaskKind, TaskLifecycle, parse_duration, stream_name_of_task,
49+
validate_desired_state_reason,
4950
};

crates/agent-spec/src/spec.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ pub enum Driver {
7878
Claude(ClaudeDriver),
7979
Codex(CodexDriver),
8080
Pi(PiDriver),
81+
OpenCode(OpenCodeDriver),
8182
}
8283

8384
impl Driver {
@@ -86,6 +87,7 @@ impl Driver {
8687
Self::Claude(_) => "claude",
8788
Self::Codex(_) => "codex",
8889
Self::Pi(_) => "pi",
90+
Self::OpenCode(_) => "opencode",
8991
}
9092
}
9193
}
@@ -128,6 +130,19 @@ pub struct CodexDriver {
128130
pub args: Vec<String>,
129131
}
130132

133+
/// Typed fields accepted by an `opencode {}` driver block.
134+
///
135+
/// OpenCode has no effort axis; its permission policy lives in its config file rather than a
136+
/// launch flag, so neither appears here.
137+
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
138+
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
139+
pub struct OpenCodeDriver {
140+
pub model: Option<String>,
141+
pub prompt: String,
142+
#[serde(default)]
143+
pub args: Vec<String>,
144+
}
145+
131146
impl AgentDesiredState {
132147
pub fn as_str(&self) -> &'static str {
133148
match self {
@@ -563,6 +578,7 @@ pub(crate) struct RawDriver {
563578
pub(crate) claude: Option<ClaudeDriver>,
564579
pub(crate) codex: Option<CodexDriver>,
565580
pub(crate) pi: Option<PiDriver>,
581+
pub(crate) opencode: Option<OpenCodeDriver>,
566582
}
567583

568584
impl RawDriver {
@@ -578,6 +594,9 @@ impl RawDriver {
578594
if let Some(driver) = self.pi {
579595
declared.push(("pi", Driver::Pi(driver)));
580596
}
597+
if let Some(driver) = self.opencode {
598+
declared.push(("opencode", Driver::OpenCode(driver)));
599+
}
581600
match declared.len() {
582601
0 => Ok(None),
583602
1 => Ok(Some(declared.pop().expect("length was just checked").1)),
@@ -912,6 +931,11 @@ impl RawSpec {
912931
|| self.deliver.is_some()
913932
|| self.driver.claude.is_some()
914933
|| self.driver.codex.is_some()
934+
// pi predates this predicate gaining driver awareness and was silently skipped too:
935+
// an identity-omitting file whose only agent-shaped signal is its driver block must
936+
// still be a candidate, whichever provider the block names.
937+
|| self.driver.pi.is_some()
938+
|| self.driver.opencode.is_some()
915939
|| !self.resource.0.is_empty()
916940
|| !self.pty.is_empty()
917941
|| !self.exec.is_empty()
@@ -1287,6 +1311,20 @@ fn validate_launch(
12871311

12881312
#[cfg(test)]
12891313
mod tests {
1314+
1315+
/// A driver block alone is an agent-shaped signal for every provider: an identity-omitting
1316+
/// `agent.toml` whose only content is `[opencode]` (or `[pi]`) must stay a spec candidate,
1317+
/// or path-derived discovery silently skips the seat.
1318+
#[test]
1319+
fn a_lone_driver_block_of_any_provider_is_a_spec_candidate() {
1320+
for provider in ["claude", "codex", "pi", "opencode"] {
1321+
let block = format!("[{provider}]\nprompt = \"Start the assigned work.\"");
1322+
let raw: super::RawSpec = toml::from_str(&block).unwrap();
1323+
assert!(raw.looks_like_spec(), "[{provider}] must look like a spec");
1324+
}
1325+
let raw: super::RawSpec = toml::from_str("unrelated = true").unwrap();
1326+
assert!(!raw.looks_like_spec());
1327+
}
12901328
use super::*;
12911329

12921330
#[test]

crates/agent-spec/tests/discovery.rs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ use std::path::Path;
99
use std::time::Duration;
1010

1111
use agent_spec::spec::{
12-
ClaudeDriver, CodexDriver, DeliveryTransport, Driver, PiDriver, TaskKind, TaskLifecycle,
12+
ClaudeDriver, CodexDriver, DeliveryTransport, Driver, OpenCodeDriver, PiDriver, TaskKind,
13+
TaskLifecycle,
1314
};
1415
use agent_spec::{
1516
AgentDesiredState, AgentSpec, JobType, Resource, Task, discover, discover_strict,
@@ -675,6 +676,41 @@ args = ["--tools", "read,bash,edit,write"]
675676
}"#,
676677
);
677678

679+
write(
680+
tmp.path(),
681+
"agents/h/opencode-kdl/agent.kdl",
682+
r#"agent "opencode-kdl" {
683+
opencode {
684+
model "anthropic/claude-opus-5"
685+
prompt "Start the assigned work."
686+
args "--agent" "build"
687+
}
688+
}"#,
689+
);
690+
write(
691+
tmp.path(),
692+
"agents/h/opencode-toml/agent.toml",
693+
r#"identity = "opencode-toml"
694+
695+
[opencode]
696+
model = "anthropic/claude-opus-5"
697+
prompt = "Start the assigned work."
698+
args = ["--agent", "build"]
699+
"#,
700+
);
701+
write(
702+
tmp.path(),
703+
"agents/h/opencode-json/agent.json",
704+
r#"{
705+
"identity": "opencode-json",
706+
"opencode": {
707+
"model": "anthropic/claude-opus-5",
708+
"prompt": "Start the assigned work.",
709+
"args": ["--agent", "build"]
710+
}
711+
}"#,
712+
);
713+
678714
let found = discover(tmp.path());
679715
assert!(found.errors.is_empty(), "{:?}", found.errors);
680716
let claude = Driver::Claude(ClaudeDriver {
@@ -711,6 +747,16 @@ args = ["--tools", "read,bash,edit,write"]
711747
assert_eq!(spec.driver.as_ref(), Some(&pi));
712748
assert!(!spec.is_runnable());
713749
}
750+
let opencode = Driver::OpenCode(OpenCodeDriver {
751+
model: Some("anthropic/claude-opus-5".into()),
752+
prompt: "Start the assigned work.".into(),
753+
args: vec!["--agent".into(), "build".into()],
754+
});
755+
for identity in ["opencode-kdl", "opencode-toml", "opencode-json"] {
756+
let spec = find(&found.specs, identity);
757+
assert_eq!(spec.driver.as_ref(), Some(&opencode));
758+
assert!(!spec.is_runnable());
759+
}
714760
}
715761

716762
#[test]
@@ -734,6 +780,19 @@ fn driver_blocks_reject_ambiguous_providers_and_untyped_fields() {
734780
),
735781
("codex-dev", r#"codex { dev-channels #true; prompt "go" }"#),
736782
("unknown", r#"claude { presence #true; prompt "go" }"#),
783+
(
784+
"pi-and-opencode",
785+
r#"pi { prompt "go" }; opencode { prompt "go" }"#,
786+
),
787+
(
788+
"opencode-effort",
789+
r#"opencode { effort "high"; prompt "go" }"#,
790+
),
791+
(
792+
"opencode-dev",
793+
r#"opencode { dev-channels #true; prompt "go" }"#,
794+
),
795+
("opencode-missing-prompt", r#"opencode { model "x/y" }"#),
737796
] {
738797
let tmp = tempfile::tempdir().unwrap();
739798
write(
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# OpenCode's server surface, measured for the driver
2+
3+
2026-08-23, OpenCode 1.18.19 (`/home/schickling/.nix-profile/bin/opencode`), Linux, isolated
4+
`XDG_DATA_HOME`/`XDG_CONFIG_HOME`, headless `opencode serve --port 43123 --print-logs`. The free
5+
anonymous model (`opencode/big-pickle`) answered prompts with no credentials, so every claim below
6+
is reproducible without an API key.
7+
8+
## What was established
9+
10+
**The TUI is a server.** `opencode` (TUI, the default command) starts a server on `--port` /
11+
`--hostname` exactly like `opencode serve`; `opencode attach <url>` exists for the reverse
12+
direction. A driver therefore launches the interactive seat with a wrapper-allocated loopback port
13+
and speaks HTTP to its own child — no screen scraping anywhere in the driver path.
14+
15+
**Observation** rides `GET /event` (SSE). First event is `server.connected`, `server.heartbeat` is
16+
periodic, and a connect replays ~45 `plugin.added` events — subscribers must tolerate noise and
17+
duplicates. The API self-describes at `GET /doc` (OpenAPI 3.1, 94 event schemas). Measured and
18+
schema-verified signals, as projected by the driver:
19+
20+
- `session.status` with a three-arm status union `busy | idle | retry` (measured firing at turn
21+
start and end; `retry` carries `attempt`/`next`/`message`);
22+
- `session.idle` fires beside the idle status (measured);
23+
- `permission.asked` / `permission.replied` and `question.asked` / `question.replied|rejected`
24+
carry stable `^per` / `^que` ids — the blocked-on-human exit edge is id-matched, with none of the
25+
Claude batching ambiguity (schema-verified; a live `permission.asked` capture is still owed —
26+
with `{"permission":{"bash":"ask"}}` PATCHed into config, the free model's bash ran without
27+
asking in one run and emitted no tool part in another);
28+
- `session.error` is an eight-arm union; `ProviderAuthError` is terminal for the seat, the others
29+
leave the session promptable;
30+
- `GET /session/status` returns `{sessionID: status}` and **omits idle sessions** — measured `{}`
31+
when idle, so absence-of-key is the idle proof only over a proven-live server.
32+
33+
**Delivery** is `POST /session/{id}/prompt_async` (measured: returns 200 immediately, empty body),
34+
which accepts a caller-supplied `messageID` (`^msg`) — idempotent and receipt-correlatable. The
35+
receipt is the message read back (`GET /session/{id}/message/{messageID}` / the `message.updated`
36+
event). Prompts sent mid-turn queue natively. **`/tui/append-prompt` and `/tui/submit-prompt`
37+
returned `true` on a headless server with no TUI attached** — they are broadcast, not receipt, and
38+
must never count as delivery. Auth is `OPENCODE_SERVER_PASSWORD` + basic auth (user `opencode`),
39+
unsecured by default on loopback.
40+
41+
**Sessions** are `ses_*`; storage moved to sqlite (`$XDG_DATA_HOME/opencode/opencode.db`,
42+
`opencode db` exists) — the API is the only sane read path. Exact resume is `--session <id>` /
43+
`--continue`, forking is `--fork`. No native incarnation concept: st2's runtime generation, the
44+
pinned port, and the wrapper pid supply fencing.
45+
46+
**Pinning**: `opencode --version` prints the bare version; `Session.version` also rides the wire.
47+
Because the server serves its own OpenAPI document, a live `/doc` subset check at wrapper start
48+
covers the shape while a version list covers the semantics — the hybrid of the Codex
49+
`SUPPORTED_CODEX_CLI_VERSIONS` pattern and the pi type-check pattern.
50+
51+
## Reproduction
52+
53+
```
54+
XDG_DATA_HOME=$S/data XDG_CONFIG_HOME=$S/config opencode serve --port 43123 --print-logs
55+
curl -s http://127.0.0.1:43123/doc | jq '.paths | keys'
56+
curl -sN http://127.0.0.1:43123/event # SSE capture
57+
curl -s -XPOST http://127.0.0.1:43123/session # create ses_…
58+
curl -s -XPOST http://127.0.0.1:43123/session/<id>/prompt_async \
59+
-H 'content-type: application/json' \
60+
-d '{"messageID":"msg0000000000000000000000000","parts":[{"type":"text","text":"hi"}]}'
61+
curl -s http://127.0.0.1:43123/session/<id>/message/msg0000000000000000000000000
62+
curl -s http://127.0.0.1:43123/session/status # {} idle · {"ses_…":{"type":"busy"}} mid-turn
63+
curl -s -XPOST http://127.0.0.1:43123/tui/append-prompt -d '{"text":"x"}' # true, no TUI attached
64+
```
65+
66+
Original captures: session `ses_fd078983affefGxfpkGr2u44LJ`, files `serve.log`, `openapi.json`,
67+
`events{,2,3,4}.sse`, `session.json`, `prompt-response.json` (session scratchpad, not committed).
68+
69+
## Follow-up capture: the blocked-on-human pairs, live (2026-08-23, second run)
70+
71+
The permission prompt fires headless after all — the first run's failure was the *write path*, not
72+
the surface: permissions set via `PATCH /config` did not take effect for asks, while the same
73+
`{"permission":{"bash":"ask","edit":"ask","webfetch":"ask"}}` in `$XDG_CONFIG_HOME/opencode/
74+
opencode.json` asks reliably with the free model and no TUI.
75+
76+
Reproduction (isolated env as above, port 43217; session `ses_fd0241376ffe3KDznnEB55qvKi`):
77+
78+
```
79+
# config file (not PATCH) carries the ask settings, then:
80+
curl -s -XPOST :43217/session/<id>/prompt_async -d '{"parts":[{"type":"text",
81+
"text":"Use the bash tool to run exactly: echo capture-test-42. Do not answer without running it."}]}'
82+
curl -s :43217/permission # pending: [{"id":"per_02fdc246b001BB5pclAd62tzpJ","permission":"bash",…}]
83+
curl -s -XPOST :43217/permission/per_…/reply -d '{"reply":"once"}' # → true; pending clears; turn completes
84+
# question: prompt "you MUST use your question tool…", then
85+
curl -s :43217/question # pending: [{"id":"que_02fdd3e83001GwptE1fgJam0jB",…}]
86+
curl -s -XPOST :43217/question/que_…/reply -d '{"answers":[["Yes"]]}'
87+
```
88+
89+
Captured event frames (verbatim, now fixture tests in `src/opencode_session.rs`):
90+
91+
```
92+
data: {"id":"evt_02fdc246b0020Xw65txB3nXBC4","type":"permission.asked","properties":{"id":"per_02fdc246b001BB5pclAd62tzpJ","sessionID":"ses_fd0241376ffe3KDznnEB55qvKi","permission":"bash","patterns":["echo capture-test-42"],"metadata":{"command":"echo capture-test-42"},"always":["echo *"],"tool":{"messageID":"msg_02fdc0989001nfz93uTCTLeO6O","callID":"call_6614fd927fe74d86ab089078"}}}
93+
data: {"id":"evt_02fdc8342001TQBwhszchZw1U6","type":"permission.replied","properties":{"sessionID":"ses_fd0241376ffe3KDznnEB55qvKi","requestID":"per_02fdc246b001BB5pclAd62tzpJ","reply":"once"}}
94+
data: {"type":"question.asked","properties":{"id":"que_02fdd3e83001GwptE1fgJam0jB",…}}
95+
data: {"type":"question.replied","properties":{"sessionID":"…","requestID":"que_02fdd3e83001GwptE1fgJam0jB","answers":[["Yes"]]}}
96+
```
97+
98+
**Two corrections to the schema-derived design, both shipped:**
99+
100+
1. **Exit events spell the id `requestID`.** Entry events carry `properties.id`; `permission.replied`
101+
and `question.replied|rejected` carry `properties.requestID`. The extraction that only knew `/id`
102+
would have held `blockedOn: human` forever after a real grant.
103+
2. **`GET /event` over HTTP/1.1 is `Transfer-Encoding: chunked`** — chunk-size lines interleave into
104+
the line-oriented SSE read and a `data:` line can split across chunks (silent event loss). The
105+
same server streams raw SSE over an HTTP/1.0 request, so the producer requests HTTP/1.0.
106+
JSON endpoints (`/config`, and `/doc` at 478 KB) responded `Content-Length` in every probe, so
107+
the one-shot request path is unaffected.
108+
109+
Also measured while live: `prompt_async` with a repeated caller `messageID` yields **one** user
110+
message (read-back receipt correlation holds; no duplicate delivery), but the second POST appends
111+
its `parts` again into that message — a resend after a *transiently failed* read-back duplicates
112+
text inside the message, not the message. The pump's read-back-before-resend rule is therefore
113+
load-bearing, not just polite.
114+
115+
## Limits
116+
117+
- A v2 surface (`/api/event`, `/api/session/{id}/wait`, `permission.v2.*`) coexists with the
118+
legacy one probed here; the driver pins the legacy arms via the `/doc` check.
119+
- Docs move fast (the site showed "Last updated Aug 23, 2026"); the `/doc` gate is the defense.
120+
- The chunked/1.0 behavior and the `requestID` spelling are measured on 1.18.19 only; both sit
121+
behind `SUPPORTED_OPENCODE_VERSIONS` and the `/doc` subset gate.
122+
123+
## VRS Impact
124+
125+
Resolves `DQ-H6` in full: the OpenCode producer is evented (server SSE), uniquely offers an
126+
id-matched blocked-on-human exit edge, and both blocked pairs are now captured live with the two
127+
wire corrections above landed as code plus verbatim fixture tests. Feeds the OpenCode producer
128+
section of `spec.md` (mapping table, aggregate-session rule, receipt semantics, the two-gate
129+
fail-closed rule) and requirement `OHS-R08`.

docs/vrs/05-harness-state/open-questions.md

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,17 @@ hypotheses.
5959
`unknown` is no fresh observation, never proof of ill health, and never
6060
gates local work. Resolves by: specifying remote-reader semantics with a
6161
proof, or explicitly scoping the record same-host advisory.
62-
- **DQ-H6 OpenCode blocked-entry capture.** The state source itself is
63-
resolved: the server's SSE event surface, measured on 1.18.19
64-
(`.experiments/2026-08-23-opencode-surface.md`) and gated by
65-
`SUPPORTED_OPENCODE_VERSIONS` plus the live `/doc` subset check. What
66-
remains open is the blocked-on-human pair: `permission.asked` /
67-
`permission.replied` are schema-backed with explicit `^per` ids — the exit
68-
edge is clean by construction, unlike Claude's — but no live capture of a
69-
real permission prompt exists (headless runs with `{"bash":"ask"}` never
70-
asked). Resolves by: one capture from a TUI seat with a real permission
71-
prompt, confirming the events fire and carry the id the producer matches.
62+
- **DQ-H6 OpenCode blocked-entry capture — resolved 2026-08-23.** Both pairs
63+
were captured live on a headless 1.18.19 server (the earlier failure to get
64+
a prompt came from setting permissions via `PATCH /config`; the same
65+
`{"permission":{"bash":"ask"}}` in the *config file* asks reliably, no TUI
66+
needed). The capture corrected the producer twice: the entry events carry
67+
`properties.id` but the exit events spell it `properties.requestID`
68+
(`permission.replied`, `question.replied|rejected`) — the schema-derived
69+
extraction would have held `blockedOn: human` forever after a real grant —
70+
and `GET /event` over HTTP/1.1 is chunk-encoded, which the line-oriented
71+
SSE reader cannot parse safely, so the producer requests it over HTTP/1.0,
72+
which the server streams raw. Verbatim captured pairs are fixture tests
73+
(`src/opencode_session.rs::captured_permission_grant_pair_enters_and_exits_blocked`,
74+
`::captured_question_reply_pair_enters_and_exits_blocked`); the raw frames
75+
and commands are in `.experiments/2026-08-23-opencode-surface.md`.

0 commit comments

Comments
 (0)