Skip to content

Commit 54daa43

Browse files
Add omp as a fifth native driver (#346)
* Add omp as a fifth native driver omp is pi-family (loads pi-style extensions, reads PI_* env fallbacks), so this follows the pi driver's shape: a typed `omp {}` block, an `omp-session` wrapper owning the presence lease and the terminal observed-state record, and an injected `omp-channel.ts` extension delivering inbox messages natively over the shared newline-JSON frame protocol. Where omp measurably diverges from pi (v18.0.3, 2026-08-25 captures in docs/vrs/06-omp-driver/.experiments/), the code diverges deliberately: - No `agent_settled` event exists, so the idle edge is `agent_end` followed by bounded `ctx.isIdle()` polling instead of pi's settled event. - omp exposes `tool_approval_requested`/`tool_approval_resolved`, which the channel projects onto the blocked-on-human axis pi cannot express; the Rust channel loop parses the optional blockedOn/ask/reason frame fields for both harnesses. - The delivery-critical surface is versioned behavior rather than API contract, so the wrapper hard-gates the provider major (18.x) under the codex/opencode admission convention instead of degrading silently. - The channel asset is forked, not shared: each harness's correctness must not depend on the other's branch. Fresh ST2_OMP_CHANNEL_* env names keep a seat from adopting stray pi channel configuration. Verified live on dev3: presence lease, seeded and transitioning observed state, restored-context seeding, and native message delivery into the running TUI. VRS for the subsystem lives in docs/vrs/06-omp-driver/ with decision 0007; DQ-OMP-1..5 record the unmeasured residuals (deny path, ask-axis discrimination, steer/modal visuals, update-banner suppression). 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@929dc21 * Tighten omp version gate to exact verified versions; treat a lone omp block as a spec candidate Review findings: the major-only comparison admitted unverified 18.x minors against OMP-R05's per-minor admission contract, so admission is now an exact verified-version list like the opencode gate; and looks_like_spec now counts an omp driver block as an agent-shaped signal, matching every other provider. 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@929dc21 * Bind idle polling to its originating channel session Codex review: a session replacement during the post-agent_end polling window could let the retired context publish idle into the successor's channel; the poll now drops its result when state.child has moved on.
1 parent 1da5809 commit 54daa43

22 files changed

Lines changed: 1449 additions & 35 deletions

crates/agent-spec/src/kdl_format.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
1010
use crate::declared::{DeclaredDocument, DeclaredNode, DeclaredValue};
1111
use crate::spec::{
12-
ClaudeDriver, CodexDriver, OpenCodeDriver, PiDriver, RawResource, RawRestart, RawSpec, RawTask,
12+
ClaudeDriver, CodexDriver, OmpDriver, OpenCodeDriver, PiDriver, RawResource, RawRestart,
13+
RawSpec, RawTask,
1314
};
1415

1516
/// Lower an already parsed declaration document into the runner's raw representation.
@@ -173,6 +174,13 @@ fn agent_node_to_raw(node: &DeclaredNode) -> anyhow::Result<RawSpec> {
173174
);
174175
raw.driver.opencode = Some(opencode_driver_node_to_raw(child)?);
175176
}
177+
"omp" => {
178+
anyhow::ensure!(
179+
raw.driver.omp.is_none(),
180+
"agent declares `omp` more than once"
181+
);
182+
raw.driver.omp = Some(omp_driver_node_to_raw(child)?);
183+
}
176184
"env" => {}
177185
"pty" => {
178186
if let Some(name) = arg_string(child) {
@@ -352,6 +360,16 @@ fn pi_driver_node_to_raw(node: &DeclaredNode) -> anyhow::Result<PiDriver> {
352360
})
353361
}
354362

363+
fn omp_driver_node_to_raw(node: &DeclaredNode) -> anyhow::Result<OmpDriver> {
364+
let (model, effort, _, prompt, args) = common_driver_fields(node, "omp", false)?;
365+
Ok(OmpDriver {
366+
model,
367+
effort,
368+
prompt,
369+
args,
370+
})
371+
}
372+
355373
fn opencode_driver_node_to_raw(node: &DeclaredNode) -> anyhow::Result<OpenCodeDriver> {
356374
let (model, effort, _, prompt, args) = common_driver_fields(node, "opencode", false)?;
357375
anyhow::ensure!(

crates/agent-spec/src/spec.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ pub enum Driver {
7979
Codex(CodexDriver),
8080
Pi(PiDriver),
8181
OpenCode(OpenCodeDriver),
82+
Omp(OmpDriver),
8283
}
8384

8485
impl Driver {
@@ -88,6 +89,7 @@ impl Driver {
8889
Self::Codex(_) => "codex",
8990
Self::Pi(_) => "pi",
9091
Self::OpenCode(_) => "opencode",
92+
Self::Omp(_) => "omp",
9193
}
9294
}
9395
}
@@ -143,6 +145,20 @@ pub struct OpenCodeDriver {
143145
pub args: Vec<String>,
144146
}
145147

148+
/// Typed fields accepted by an `omp {}` driver block.
149+
///
150+
/// omp is pi-family and exposes the same two axes under the same flags: `effort` carries omp's
151+
/// thinking level verbatim (`--thinking`), exactly as [`PiDriver`] does for pi.
152+
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
153+
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
154+
pub struct OmpDriver {
155+
pub model: Option<String>,
156+
pub effort: Option<String>,
157+
pub prompt: String,
158+
#[serde(default)]
159+
pub args: Vec<String>,
160+
}
161+
146162
impl AgentDesiredState {
147163
pub fn as_str(&self) -> &'static str {
148164
match self {
@@ -579,6 +595,7 @@ pub(crate) struct RawDriver {
579595
pub(crate) codex: Option<CodexDriver>,
580596
pub(crate) pi: Option<PiDriver>,
581597
pub(crate) opencode: Option<OpenCodeDriver>,
598+
pub(crate) omp: Option<OmpDriver>,
582599
}
583600

584601
impl RawDriver {
@@ -597,6 +614,9 @@ impl RawDriver {
597614
if let Some(driver) = self.opencode {
598615
declared.push(("opencode", Driver::OpenCode(driver)));
599616
}
617+
if let Some(driver) = self.omp {
618+
declared.push(("omp", Driver::Omp(driver)));
619+
}
600620
match declared.len() {
601621
0 => Ok(None),
602622
1 => Ok(Some(declared.pop().expect("length was just checked").1)),
@@ -936,6 +956,7 @@ impl RawSpec {
936956
// still be a candidate, whichever provider the block names.
937957
|| self.driver.pi.is_some()
938958
|| self.driver.opencode.is_some()
959+
|| self.driver.omp.is_some()
939960
|| !self.resource.0.is_empty()
940961
|| !self.pty.is_empty()
941962
|| !self.exec.is_empty()
@@ -1317,7 +1338,7 @@ mod tests {
13171338
/// or path-derived discovery silently skips the seat.
13181339
#[test]
13191340
fn a_lone_driver_block_of_any_provider_is_a_spec_candidate() {
1320-
for provider in ["claude", "codex", "pi", "opencode"] {
1341+
for provider in ["claude", "codex", "pi", "opencode", "omp"] {
13211342
let block = format!("[{provider}]\nprompt = \"Start the assigned work.\"");
13221343
let raw: super::RawSpec = toml::from_str(&block).unwrap();
13231344
assert!(raw.looks_like_spec(), "[{provider}] must look like a spec");
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# omp is a fifth native driver with its own channel and a hard version gate
2+
3+
Status: accepted
4+
5+
## Context
6+
7+
st2 maintains four typed drivers (claude, codex, pi, opencode), each pairing a pure KDL
8+
expansion with a session wrapper that owns presence, publishes observed harness state, and
9+
delivers inbox messages natively. omp — an earendil-works/pi-family harness in daily use on
10+
this fleet — has none of this: seats are hand-authored tasks with no presence lease, no
11+
observed state, and no delivery path. The question was how deep support should go: a full
12+
native driver, an alias onto the pi driver's machinery, or a documented hand-authored
13+
pattern.
14+
15+
## Options
16+
| Option | Result | Reason |
17+
| --- | --- | --- |
18+
| Full native driver (fifth expansion arm, own wrapper, own channel) | Selected | The 2026-08-25 capture shows the pi mechanism ports and omp's approval events add an axis pi lacks. Cost: ~pi-driver scale code and a per-minor admission checklist. |
19+
| Alias onto the pi driver | Rejected | Cheapest, but measured divergence makes it wrong: no `agent_settled` means the observed idle edge never fires or blips at `agent_end`, and version gates would pin the wrong harness. Rejected on evidence, not effort. |
20+
| Docs-only hand-authored pattern | Rejected | No presence lease, observed state, or native delivery; inconsistent with all four existing drivers — not "first-class". |
21+
| Blocked axis deferred out of v1 | Rejected | Smaller diff, but omp seats would read busy while actually waiting on a human — exactly what st2's wedged-agent signal exists to catch; both events verified firing (q2). |
22+
| Warn-only version handling | Rejected | omp releases near-daily; silent degradation reads as healthy while a refused launch is loud (q3). |
23+
24+
## Evidence and Argument
25+
26+
Measured against omp v18.0.3 on 2026-08-25; full record:
27+
[`06-omp-driver/.experiments/2026-08-25-omp-harness-integration.md`](../06-omp-driver/.experiments/2026-08-25-omp-harness-integration.md).
28+
29+
- **The pi mechanism ports.** omp loads pi-style extensions; its extension argument carries
30+
the same `sendUserMessage` / `sendMessage` / `on` calls; a live interactive run delivered
31+
an idle message end-to-end and drove a complete model turn without touching a screen.
32+
- **But it is not pi.** `agent_settled` — the pi channel's entire idle edge — does not exist
33+
(absent from the binary); idle must be derived by polling `ctx.isIdle()` after
34+
`agent_end`. Conversely omp is *richer* than pi where it matters for st2: it exposes
35+
`tool_approval_requested` / `tool_approval_resolved` with a `toolCallId`, giving the
36+
blocked-on-human axis pi cannot express at all.
37+
38+
An alias onto the pi driver would bake both divergences into the wrong place: observed state
39+
would hang waiting for an event that never fires or blip idle at the wrong boundary, and
40+
each future divergence would become a special case inside "pi". The measured differences
41+
are exactly why the channel forks rather than shares a file.
42+
43+
## Decision
44+
45+
1. **Full native driver** (`driver omp` block + `omp-session` wrapper + `omp-channel.ts`
46+
extension + `omp-channel` process), per OMP-R01.
47+
2. **Own channel asset, forked from pi's**, not a shared parameterized file — the idle-edge
48+
and approval logic differ per harness (OMP-R04).
49+
3. **Publish the blocked-on-human axis in v1** from the two approval events (OMP-R02) —
50+
verified firing, not speculative.
51+
4. **Hard version gate on the minor** (18.x initially) under the codex/opencode admission
52+
convention, because the delivery-critical surface is versioned behavior, not API contract
53+
(OMP-R05). Chosen over warn-only despite omp's near-daily releases: a silently degraded
54+
fleet reads as healthy; a refused launch reads as what it is.
55+
5. **No DING screen adapter for omp** in v1 (OMP-T03), matching the other native-channel
56+
drivers.
57+
58+
Interview decisions q1–q4 (2026-08-25, Johannes): full native driver; blocked axis included;
59+
hard gate; VRS lives in [`06-omp-driver/`](../06-omp-driver/).
60+
61+
## Consequences
62+
63+
- A fifth expansion arm, wrapper module, channel process, hook asset, and ding launch
64+
classification follow the existing per-harness pattern.
65+
- Every omp minor bump costs one capture run before admission; the required checklist is in
66+
the subsystem spec.
67+
- The deny path, ask-axis discrimination, steer/modal interactions, and update-banner
68+
suppression remain open (`DQ-OMP-1..5`) and bound v1's claims.
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# omp harness integration surface
2+
3+
## Question
4+
5+
Does st2's pi-driver mechanism — an injected TypeScript extension speaking newline-delimited
6+
JSON frames over stdio to a channel process — port to omp, and what diverges? Sub-questions:
7+
which lifecycle events exist, when does omp become provably idle, does native delivery drive a
8+
real turn, and is there any waiting-on-human signal?
9+
10+
Date: 2026-08-25. Binary: omp v18.0.3 (`omp` on dev3 PATH, Nix store
11+
`zv3xvic38a2gcdpxk3wd5fsk6plsgvbn-omp-18.0.3`). Linux x86_64, driven through live `omp`
12+
processes (print mode for API probes, a PTY-held interactive TUI for lifecycle and delivery
13+
runs).
14+
15+
## Method
16+
17+
18+
Six throwaway TypeScript extensions, each loaded via `omp -e ./probeN.ts`, writing observations
19+
to files beside them. Print-mode runs used `--no-session --no-tools --no-lsp --no-skills
20+
--no-rules`. Interactive runs held the TUI in a supervised PTY session and drove prompts by
21+
typing into the pane; one run forced approvals with `--approval-mode always-ask`.
22+
23+
## Result
24+
25+
**omp is pi-family.** omp reads pi's env fallbacks (`PI_SMOL_MODEL` documented in `--help`),
26+
loads pi-style default-export TypeScript extensions unchanged at the module boundary, and its
27+
extension first argument carries the same three calls st2's pi channel uses:
28+
`sendUserMessage(content, options?)`, `sendMessage(message, options?)`, `on(event, handler)`.
29+
The argument additionally carries an internals namespace under `.pi`; irrelevant to st2.
30+
31+
**Lifecycle events use pi's names, minus `agent_settled`.** Observed firing in one interactive
32+
turn: `session_start`, `agent_start`, `turn_start`, `message_start`, `message_end`,
33+
`turn_end`, `agent_end`, `session_shutdown`. `agent_settled` never fired and the string does
34+
not occur anywhere in the binary (1M-string scan). Handler registration for unknown names does
35+
not throw, so absence is silent.
36+
37+
**The idle edge exists but is sampled, not evented.** In the interactive run,
38+
`ctx.isIdle()` was still `false` at `agent_end` and flipped `true` by the +251 ms sample. Rule:
39+
idle is `agent_end` followed by bounded polling until `isIdle()` is true; a queued follow-up
40+
turn keeps it false, so no spurious idle blip.
41+
42+
**Idle delivery lands and drives a full turn.** Interactive run with tools enabled: extension
43+
polled a trigger file every 200 ms, called `pi.sendUserMessage(body)` while idle; the model
44+
received it, acted (wrote the requested file correctly), turn events fired, and the session
45+
settled back to idle. End-to-end native delivery confirmed without touching any screen.
46+
47+
**Mid-turn steer accepted in print mode.** From inside `message_start`, `sendUserMessage(text,
48+
{ deliverAs: "steer" })` returned without error. Not yet visually verified in the interactive
49+
TUI (DQ-OMP-4).
50+
51+
**Approval events exist and fire — omp is richer than pi here.** Under
52+
`--approval-mode always-ask`, a bash call produced:
53+
54+
```
55+
tool_approval_requested { type, sessionId, toolName: "bash", toolCallId, approvalMode }
56+
tool_approval_resolved { ..., approved: true }
57+
```
58+
59+
The requested event carries a correlating `toolCallId`, giving the blocked exit edge pi lacks
60+
entirely. Without forced approval mode the events did not fire (the same command auto-ran).
61+
62+
**Context object differences.** Event-handler `ctx` exposes `{ ui }` only — no abort `signal`
63+
(pi's channel uses `ctx.signal?.addEventListener` optionally, so the fork loses nothing).
64+
`ctx.ui.notify` is present.
65+
66+
**Unresolved by this capture:** the update-check banner appeared in every interactive boot;
67+
whether `PI_OFFLINE`/`PI_SKIP_VERSION_CHECK` suppress it was not established in print mode
68+
(the banner never renders there) — DQ-OMP-5.
69+
70+
## Conclusion
71+
72+
The pi-driver mechanism ports to omp with one structural divergence: the idle edge must be
73+
derived by polling `ctx.isIdle()` after `agent_end` instead of listening for `agent_settled`.
74+
A native omp driver is viable at full parity — presence lease, observed state including the
75+
blocked-on-human axis pi cannot express, and native delivery. The measured divergences
76+
justify forking the channel asset rather than parameterizing pi's.
77+
78+
## VRS Impact
79+
80+
Grounds OMP-R02 (observed axes, from the approval-event payloads and idle sampling),
81+
OMP-R03 (idle edge rule), OMP-R04 (fork rationale), OMP-T01/T02 (deny path and ask axis
82+
unmeasured), and DQ-OMP-3..5 (steer visual, modal interaction, banner suppression).
83+
84+
## Driver e2e run
85+
86+
The same day, the implemented driver ran end-to-end on dev3: a scratch catalog declared
87+
`agent "omp-smoke"` with a `driver omp` block; `st2 hooks install` published the set including
88+
`omp-channel.ts`; `st2 driver omp-session` launched the TUI under the wrapper. Verified: the
89+
presence record read `available`; the harness-state record seeded `idle` under
90+
`harness: "omp"`, fenced by the wrapper's session token (`seq 1`, pty session bound); a
91+
`st2 message send` landed in the live TUI via the channel together with the
92+
`st2-session-start` restored-context block; observed state cycled active → idle
93+
(transitions 3→5) around the delivery. The model's reply itself failed with a provider-side
94+
429 weekly usage limit — outside st2's surface.
95+
96+
## Fleet e2e run (dotfiles integration)
97+
98+
The dotfiles side activated the driver on dev3 through the standing
99+
`dev3.omp-scratch` seat (launched by the production supervisor via
100+
`st2 driver omp-session`, st2 repinned to this branch). Verified live: the
101+
seat materialized into the catalog, reached presence `available`, published
102+
the harness-state record (`harness: "omp"`, seeded idle, fenced by the
103+
wrapper's session token), and a `st2 message send` from `dev3.cos` landed in
104+
its live TUI through the channel with observed state cycling active → idle
105+
(transitions around the delivery). The model's reply was blocked by a
106+
provider-side 429 weekly usage limit on the opencode-go workspace - the same
107+
external quota exhaustion visible across the fleet that day; the
108+
model-acts-on-delivery step is covered by the manual runs above.
109+
110+
Integration findings recorded on the way: Nix standing seats launch through
111+
the `axe agent launch` carrier, which spawns the raw harness binary - so a
112+
driver-backed seat that wants the wrapper's machinery must declare the
113+
`st2 driver <h>-session` argv directly rather than ride the axe carrier; and
114+
axe's managed path requires a profile account binding plus a fixed-account
115+
credential availability probe, which omp satisfies with its install-identity
116+
file since its native OAuth exposes no projectable credential.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# omp driver open questions
2+
3+
Each entry links a spec `DQ-OMP-*`. Questions leave this file when resolved — into
4+
[spec.md](./spec.md) as decisions or `.experiments/` as tested hypotheses.
5+
6+
- **DQ-OMP-1 Deny-path semantics.** What omp does after
7+
`tool_approval_resolved { approved: false }` — whether the turn ends (Claude's deny path:
8+
turn ends eventlessly) or the model continues with a denial result. Until captured,
9+
OMP-T01 accepts a possibly brief misprojection of activity after a denial.
10+
- **DQ-OMP-2 Ask-axis discrimination.** Whether an AskUserQuestion-equivalent surface in omp
11+
arrives as `tool_approval_requested` with a distinguishable `toolName`, so v1's coarse
12+
`ask: permission` can be split into question vs permission like the Claude driver does.
13+
Needs one capture of each prompt kind under forced approval mode.
14+
- **DQ-OMP-3 Mid-turn steer in the live TUI.** `deliverAs: "steer"` was accepted without
15+
error in print mode; the interactive visual (message lands as a queued steer, not a lost
16+
send) is unconfirmed. Resolves by repeating the delivery experiment with the trigger fired
17+
during an active turn and reading the resulting transcript.
18+
- **DQ-OMP-4 Modal interaction.** pi's capture showed an open `/model` modal does not corrupt
19+
idle delivery; omp's equivalent has not been tested. Same method: open a picker, deliver
20+
while idle-true, verify the modal survives.
21+
- **DQ-OMP-5 Update-banner suppression.** Interactive boots showed the update banner;
22+
whether `PI_OFFLINE` / `PI_SKIP_VERSION_CHECK` suppress it was not establishable in print
23+
mode. Resolves by one interactive boot with the env set. The wrapper ships the env either
24+
way (harmless if inert).

0 commit comments

Comments
 (0)