Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
78a7cb7
feat(opencode): full driver — typed expansion, session wrapper, obser…
schickling Aug 23, 2026
91fbfc4
fix(opencode): match the measured wire — replied events carry request…
schickling Aug 23, 2026
ca97147
docs(vrs): resolve DQ-H6 with the live blocked-pair capture
schickling Aug 23, 2026
d865843
fix(opencode): seed-gated evidence, reconnect ask recovery, read-back…
schickling Aug 23, 2026
b3746ec
fix(opencode): question re-seeding, session-boundary starts, ask kind…
schickling Aug 23, 2026
6f01253
fix(opencode): gate every consumed arm, seed atomically, and count on…
schickling Aug 23, 2026
12ae566
fix(opencode): recover pre-settled delivery targets, trust only pinne…
schickling Aug 23, 2026
5f95070
fix(opencode,agent-spec): written ownership claim and the missing dri…
schickling Aug 23, 2026
1a79a8d
fix(opencode): claim before the provider spawns — a failed claim leak…
schickling Aug 23, 2026
019b6a8
fix(opencode): atomic whole-truth seeding, and delivery targets only …
schickling Aug 23, 2026
2c12456
fix(opencode): a silence horizon on the stream, and only object-shape…
schickling Aug 24, 2026
3489c55
fix(opencode): a failed liveness check ends the record honestly
schickling Aug 24, 2026
57a65f7
fix(opencode): a disconnect breaks continuity, an unreadable status w…
schickling Aug 24, 2026
c2ca285
fix(opencode): a sticky terminal outranks the poison
schickling Aug 24, 2026
fca92a5
fix(opencode): every unreadable status poisons, spawn failures end th…
schickling-assistant Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion crates/agent-spec/src/kdl_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
//! ignored.

use crate::declared::{DeclaredDocument, DeclaredNode, DeclaredValue};
use crate::spec::{ClaudeDriver, CodexDriver, PiDriver, RawResource, RawRestart, RawSpec, RawTask};
use crate::spec::{
ClaudeDriver, CodexDriver, OpenCodeDriver, PiDriver, RawResource, RawRestart, RawSpec, RawTask,
};

/// Lower an already parsed declaration document into the runner's raw representation.
pub(crate) fn lower_declared_document(document: &DeclaredDocument) -> anyhow::Result<Vec<RawSpec>> {
Expand Down Expand Up @@ -164,6 +166,13 @@ fn agent_node_to_raw(node: &DeclaredNode) -> anyhow::Result<RawSpec> {
);
raw.driver.pi = Some(pi_driver_node_to_raw(child)?);
}
"opencode" => {
anyhow::ensure!(
raw.driver.opencode.is_none(),
"agent declares `opencode` more than once"
);
raw.driver.opencode = Some(opencode_driver_node_to_raw(child)?);
}
"env" => {}
"pty" => {
if let Some(name) = arg_string(child) {
Expand Down Expand Up @@ -343,6 +352,19 @@ fn pi_driver_node_to_raw(node: &DeclaredNode) -> anyhow::Result<PiDriver> {
})
}

fn opencode_driver_node_to_raw(node: &DeclaredNode) -> anyhow::Result<OpenCodeDriver> {
let (model, effort, _, prompt, args) = common_driver_fields(node, "opencode", false)?;
anyhow::ensure!(
effort.is_none(),
"agent `opencode` has unsupported field `effort` (OpenCode has no effort axis)"
);
Ok(OpenCodeDriver {
model,
prompt,
args,
})
}

fn parse_presentation(
node: &DeclaredNode,
field: &str,
Expand Down
5 changes: 3 additions & 2 deletions crates/agent-spec/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ pub use discovery::{
};
pub use spec::{
AgentDesiredState, AgentSpec, ClaudeDriver, CodexDriver, DeliveryTransport, Driver, JobType,
PiDriver, Resource, Restart, RestartMode, STREAM_TASK_PREFIX, Stream, StreamLaunch, Task,
TaskKind, TaskLifecycle, parse_duration, stream_name_of_task, validate_desired_state_reason,
OpenCodeDriver, PiDriver, Resource, Restart, RestartMode, STREAM_TASK_PREFIX, Stream,
StreamLaunch, Task, TaskKind, TaskLifecycle, parse_duration, stream_name_of_task,
validate_desired_state_reason,
};
38 changes: 38 additions & 0 deletions crates/agent-spec/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ pub enum Driver {
Claude(ClaudeDriver),
Codex(CodexDriver),
Pi(PiDriver),
OpenCode(OpenCodeDriver),
}

impl Driver {
Expand All @@ -86,6 +87,7 @@ impl Driver {
Self::Claude(_) => "claude",
Self::Codex(_) => "codex",
Self::Pi(_) => "pi",
Self::OpenCode(_) => "opencode",
}
}
}
Expand Down Expand Up @@ -128,6 +130,19 @@ pub struct CodexDriver {
pub args: Vec<String>,
}

/// Typed fields accepted by an `opencode {}` driver block.
///
/// OpenCode has no effort axis; its permission policy lives in its config file rather than a
/// launch flag, so neither appears here.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct OpenCodeDriver {
pub model: Option<String>,
pub prompt: String,
#[serde(default)]
pub args: Vec<String>,
Comment thread
schickling marked this conversation as resolved.
}

impl AgentDesiredState {
pub fn as_str(&self) -> &'static str {
match self {
Expand Down Expand Up @@ -563,6 +578,7 @@ pub(crate) struct RawDriver {
pub(crate) claude: Option<ClaudeDriver>,
pub(crate) codex: Option<CodexDriver>,
pub(crate) pi: Option<PiDriver>,
pub(crate) opencode: Option<OpenCodeDriver>,
Comment thread
schickling marked this conversation as resolved.
}

impl RawDriver {
Expand All @@ -578,6 +594,9 @@ impl RawDriver {
if let Some(driver) = self.pi {
declared.push(("pi", Driver::Pi(driver)));
}
if let Some(driver) = self.opencode {
declared.push(("opencode", Driver::OpenCode(driver)));
}
match declared.len() {
0 => Ok(None),
1 => Ok(Some(declared.pop().expect("length was just checked").1)),
Expand Down Expand Up @@ -912,6 +931,11 @@ impl RawSpec {
|| self.deliver.is_some()
|| self.driver.claude.is_some()
|| self.driver.codex.is_some()
// pi predates this predicate gaining driver awareness and was silently skipped too:
// an identity-omitting file whose only agent-shaped signal is its driver block must
// still be a candidate, whichever provider the block names.
|| self.driver.pi.is_some()
|| self.driver.opencode.is_some()
|| !self.resource.0.is_empty()
|| !self.pty.is_empty()
|| !self.exec.is_empty()
Expand Down Expand Up @@ -1287,6 +1311,20 @@ fn validate_launch(

#[cfg(test)]
mod tests {

/// A driver block alone is an agent-shaped signal for every provider: an identity-omitting
/// `agent.toml` whose only content is `[opencode]` (or `[pi]`) must stay a spec candidate,
/// or path-derived discovery silently skips the seat.
#[test]
fn a_lone_driver_block_of_any_provider_is_a_spec_candidate() {
for provider in ["claude", "codex", "pi", "opencode"] {
let block = format!("[{provider}]\nprompt = \"Start the assigned work.\"");
let raw: super::RawSpec = toml::from_str(&block).unwrap();
assert!(raw.looks_like_spec(), "[{provider}] must look like a spec");
}
let raw: super::RawSpec = toml::from_str("unrelated = true").unwrap();
assert!(!raw.looks_like_spec());
}
use super::*;

#[test]
Expand Down
61 changes: 60 additions & 1 deletion crates/agent-spec/tests/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ use std::path::Path;
use std::time::Duration;

use agent_spec::spec::{
ClaudeDriver, CodexDriver, DeliveryTransport, Driver, PiDriver, TaskKind, TaskLifecycle,
ClaudeDriver, CodexDriver, DeliveryTransport, Driver, OpenCodeDriver, PiDriver, TaskKind,
TaskLifecycle,
};
use agent_spec::{
AgentDesiredState, AgentSpec, JobType, Resource, Task, discover, discover_strict,
Expand Down Expand Up @@ -675,6 +676,41 @@ args = ["--tools", "read,bash,edit,write"]
}"#,
);

write(
tmp.path(),
"agents/h/opencode-kdl/agent.kdl",
r#"agent "opencode-kdl" {
opencode {
model "anthropic/claude-opus-5"
prompt "Start the assigned work."
args "--agent" "build"
}
}"#,
);
write(
tmp.path(),
"agents/h/opencode-toml/agent.toml",
r#"identity = "opencode-toml"

[opencode]
model = "anthropic/claude-opus-5"
prompt = "Start the assigned work."
args = ["--agent", "build"]
"#,
);
write(
tmp.path(),
"agents/h/opencode-json/agent.json",
r#"{
"identity": "opencode-json",
"opencode": {
"model": "anthropic/claude-opus-5",
"prompt": "Start the assigned work.",
"args": ["--agent", "build"]
}
}"#,
);

let found = discover(tmp.path());
assert!(found.errors.is_empty(), "{:?}", found.errors);
let claude = Driver::Claude(ClaudeDriver {
Expand Down Expand Up @@ -711,6 +747,16 @@ args = ["--tools", "read,bash,edit,write"]
assert_eq!(spec.driver.as_ref(), Some(&pi));
assert!(!spec.is_runnable());
}
let opencode = Driver::OpenCode(OpenCodeDriver {
model: Some("anthropic/claude-opus-5".into()),
prompt: "Start the assigned work.".into(),
args: vec!["--agent".into(), "build".into()],
});
for identity in ["opencode-kdl", "opencode-toml", "opencode-json"] {
let spec = find(&found.specs, identity);
assert_eq!(spec.driver.as_ref(), Some(&opencode));
assert!(!spec.is_runnable());
}
}

#[test]
Expand All @@ -734,6 +780,19 @@ fn driver_blocks_reject_ambiguous_providers_and_untyped_fields() {
),
("codex-dev", r#"codex { dev-channels #true; prompt "go" }"#),
("unknown", r#"claude { presence #true; prompt "go" }"#),
(
"pi-and-opencode",
r#"pi { prompt "go" }; opencode { prompt "go" }"#,
),
(
"opencode-effort",
r#"opencode { effort "high"; prompt "go" }"#,
),
(
"opencode-dev",
r#"opencode { dev-channels #true; prompt "go" }"#,
),
("opencode-missing-prompt", r#"opencode { model "x/y" }"#),
] {
let tmp = tempfile::tempdir().unwrap();
write(
Expand Down
129 changes: 129 additions & 0 deletions docs/vrs/05-harness-state/.experiments/2026-08-23-opencode-surface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# OpenCode's server surface, measured for the driver

2026-08-23, OpenCode 1.18.19 (`/home/schickling/.nix-profile/bin/opencode`), Linux, isolated
`XDG_DATA_HOME`/`XDG_CONFIG_HOME`, headless `opencode serve --port 43123 --print-logs`. The free
anonymous model (`opencode/big-pickle`) answered prompts with no credentials, so every claim below
is reproducible without an API key.

## What was established

**The TUI is a server.** `opencode` (TUI, the default command) starts a server on `--port` /
`--hostname` exactly like `opencode serve`; `opencode attach <url>` exists for the reverse
direction. A driver therefore launches the interactive seat with a wrapper-allocated loopback port
and speaks HTTP to its own child — no screen scraping anywhere in the driver path.

**Observation** rides `GET /event` (SSE). First event is `server.connected`, `server.heartbeat` is
periodic, and a connect replays ~45 `plugin.added` events — subscribers must tolerate noise and
duplicates. The API self-describes at `GET /doc` (OpenAPI 3.1, 94 event schemas). Measured and
schema-verified signals, as projected by the driver:

- `session.status` with a three-arm status union `busy | idle | retry` (measured firing at turn
start and end; `retry` carries `attempt`/`next`/`message`);
- `session.idle` fires beside the idle status (measured);
- `permission.asked` / `permission.replied` and `question.asked` / `question.replied|rejected`
carry stable `^per` / `^que` ids — the blocked-on-human exit edge is id-matched, with none of the
Claude batching ambiguity (schema-verified; a live `permission.asked` capture is still owed —
with `{"permission":{"bash":"ask"}}` PATCHed into config, the free model's bash ran without
asking in one run and emitted no tool part in another);
- `session.error` is an eight-arm union; `ProviderAuthError` is terminal for the seat, the others
leave the session promptable;
- `GET /session/status` returns `{sessionID: status}` and **omits idle sessions** — measured `{}`
when idle, so absence-of-key is the idle proof only over a proven-live server.

**Delivery** is `POST /session/{id}/prompt_async` (measured: returns 200 immediately, empty body),
which accepts a caller-supplied `messageID` (`^msg`) — idempotent and receipt-correlatable. The
receipt is the message read back (`GET /session/{id}/message/{messageID}` / the `message.updated`
event). Prompts sent mid-turn queue natively. **`/tui/append-prompt` and `/tui/submit-prompt`
returned `true` on a headless server with no TUI attached** — they are broadcast, not receipt, and
must never count as delivery. Auth is `OPENCODE_SERVER_PASSWORD` + basic auth (user `opencode`),
unsecured by default on loopback.

**Sessions** are `ses_*`; storage moved to sqlite (`$XDG_DATA_HOME/opencode/opencode.db`,
`opencode db` exists) — the API is the only sane read path. Exact resume is `--session <id>` /
`--continue`, forking is `--fork`. No native incarnation concept: st2's runtime generation, the
pinned port, and the wrapper pid supply fencing.

**Pinning**: `opencode --version` prints the bare version; `Session.version` also rides the wire.
Because the server serves its own OpenAPI document, a live `/doc` subset check at wrapper start
covers the shape while a version list covers the semantics — the hybrid of the Codex
`SUPPORTED_CODEX_CLI_VERSIONS` pattern and the pi type-check pattern.

## Reproduction

```
XDG_DATA_HOME=$S/data XDG_CONFIG_HOME=$S/config opencode serve --port 43123 --print-logs
curl -s http://127.0.0.1:43123/doc | jq '.paths | keys'
curl -sN http://127.0.0.1:43123/event # SSE capture
curl -s -XPOST http://127.0.0.1:43123/session # create ses_…
curl -s -XPOST http://127.0.0.1:43123/session/<id>/prompt_async \
-H 'content-type: application/json' \
-d '{"messageID":"msg0000000000000000000000000","parts":[{"type":"text","text":"hi"}]}'
curl -s http://127.0.0.1:43123/session/<id>/message/msg0000000000000000000000000
curl -s http://127.0.0.1:43123/session/status # {} idle · {"ses_…":{"type":"busy"}} mid-turn
curl -s -XPOST http://127.0.0.1:43123/tui/append-prompt -d '{"text":"x"}' # true, no TUI attached
```

Original captures: session `ses_fd078983affefGxfpkGr2u44LJ`, files `serve.log`, `openapi.json`,
`events{,2,3,4}.sse`, `session.json`, `prompt-response.json` (session scratchpad, not committed).

## Follow-up capture: the blocked-on-human pairs, live (2026-08-23, second run)

The permission prompt fires headless after all — the first run's failure was the *write path*, not
the surface: permissions set via `PATCH /config` did not take effect for asks, while the same
`{"permission":{"bash":"ask","edit":"ask","webfetch":"ask"}}` in `$XDG_CONFIG_HOME/opencode/
opencode.json` asks reliably with the free model and no TUI.

Reproduction (isolated env as above, port 43217; session `ses_fd0241376ffe3KDznnEB55qvKi`):

```
# config file (not PATCH) carries the ask settings, then:
curl -s -XPOST :43217/session/<id>/prompt_async -d '{"parts":[{"type":"text",
"text":"Use the bash tool to run exactly: echo capture-test-42. Do not answer without running it."}]}'
curl -s :43217/permission # pending: [{"id":"per_02fdc246b001BB5pclAd62tzpJ","permission":"bash",…}]
curl -s -XPOST :43217/permission/per_…/reply -d '{"reply":"once"}' # → true; pending clears; turn completes
# question: prompt "you MUST use your question tool…", then
curl -s :43217/question # pending: [{"id":"que_02fdd3e83001GwptE1fgJam0jB",…}]
curl -s -XPOST :43217/question/que_…/reply -d '{"answers":[["Yes"]]}'
```

Captured event frames (verbatim, now fixture tests in `src/opencode_session.rs`):

```
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"}}}
data: {"id":"evt_02fdc8342001TQBwhszchZw1U6","type":"permission.replied","properties":{"sessionID":"ses_fd0241376ffe3KDznnEB55qvKi","requestID":"per_02fdc246b001BB5pclAd62tzpJ","reply":"once"}}
data: {"type":"question.asked","properties":{"id":"que_02fdd3e83001GwptE1fgJam0jB",…}}
data: {"type":"question.replied","properties":{"sessionID":"…","requestID":"que_02fdd3e83001GwptE1fgJam0jB","answers":[["Yes"]]}}
```

**Two corrections to the schema-derived design, both shipped:**

1. **Exit events spell the id `requestID`.** Entry events carry `properties.id`; `permission.replied`
and `question.replied|rejected` carry `properties.requestID`. The extraction that only knew `/id`
would have held `blockedOn: human` forever after a real grant.
2. **`GET /event` over HTTP/1.1 is `Transfer-Encoding: chunked`** — chunk-size lines interleave into
the line-oriented SSE read and a `data:` line can split across chunks (silent event loss). The
same server streams raw SSE over an HTTP/1.0 request, so the producer requests HTTP/1.0.
JSON endpoints (`/config`, and `/doc` at 478 KB) responded `Content-Length` in every probe, so
the one-shot request path is unaffected.

Also measured while live: `prompt_async` with a repeated caller `messageID` yields **one** user
message (read-back receipt correlation holds; no duplicate delivery), but the second POST appends
its `parts` again into that message — a resend after a *transiently failed* read-back duplicates
text inside the message, not the message. The pump's read-back-before-resend rule is therefore
load-bearing, not just polite.

## Limits

- A v2 surface (`/api/event`, `/api/session/{id}/wait`, `permission.v2.*`) coexists with the
legacy one probed here; the driver pins the legacy arms via the `/doc` check.
- Docs move fast (the site showed "Last updated Aug 23, 2026"); the `/doc` gate is the defense.
- The chunked/1.0 behavior and the `requestID` spelling are measured on 1.18.19 only; both sit
behind `SUPPORTED_OPENCODE_VERSIONS` and the `/doc` subset gate.

## VRS Impact

Resolves `DQ-H6` in full: the OpenCode producer is evented (server SSE), uniquely offers an
id-matched blocked-on-human exit edge, and both blocked pairs are now captured live with the two
wire corrections above landed as code plus verbatim fixture tests. Feeds the OpenCode producer
section of `spec.md` (mapping table, aggregate-session rule, receipt semantics, the two-gate
fail-closed rule) and requirement `OHS-R08`.
24 changes: 14 additions & 10 deletions docs/vrs/05-harness-state/open-questions.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,17 @@ hypotheses.
`unknown` is no fresh observation, never proof of ill health, and never
gates local work. Resolves by: specifying remote-reader semantics with a
proof, or explicitly scoping the record same-host advisory.
- **DQ-H6 OpenCode blocked-entry capture.** The state source itself is
resolved: the server's SSE event surface, measured on 1.18.19
(`.experiments/2026-08-23-opencode-surface.md`) and gated by
`SUPPORTED_OPENCODE_VERSIONS` plus the live `/doc` subset check. What
remains open is the blocked-on-human pair: `permission.asked` /
`permission.replied` are schema-backed with explicit `^per` ids — the exit
edge is clean by construction, unlike Claude's — but no live capture of a
real permission prompt exists (headless runs with `{"bash":"ask"}` never
asked). Resolves by: one capture from a TUI seat with a real permission
prompt, confirming the events fire and carry the id the producer matches.
- **DQ-H6 OpenCode blocked-entry capture — resolved 2026-08-23.** Both pairs
were captured live on a headless 1.18.19 server (the earlier failure to get
a prompt came from setting permissions via `PATCH /config`; the same
`{"permission":{"bash":"ask"}}` in the *config file* asks reliably, no TUI
needed). The capture corrected the producer twice: the entry events carry
`properties.id` but the exit events spell it `properties.requestID`
(`permission.replied`, `question.replied|rejected`) — the schema-derived
extraction would have held `blockedOn: human` forever after a real grant —
and `GET /event` over HTTP/1.1 is chunk-encoded, which the line-oriented
SSE reader cannot parse safely, so the producer requests it over HTTP/1.0,
which the server streams raw. Verbatim captured pairs are fixture tests
(`src/opencode_session.rs::captured_permission_grant_pair_enters_and_exits_blocked`,
`::captured_question_reply_pair_enters_and_exits_blocked`); the raw frames
and commands are in `.experiments/2026-08-23-opencode-surface.md`.
Loading
Loading