Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,40 @@ Versioning and Keep a Changelog conventions.

### Added

- Bidirectional OpenCode support through `opencode serve`. `OpenCodeTurnMode`
selects the transport; `OpenCode::serve()` starts the server on a reserved
loopback port and drives it over HTTP and Server-Sent Events, adding live
approvals (`once`/`always`/`reject`), incremental text and reasoning deltas,
tool lifecycle events, and a cooperative `session/abort` on cancellation.
`PermissionSupport` reports `live_approvals` in that mode. The default
`opencode run --format json` transport is unchanged and still reports
`live_approvals: false`.
- Enforced per-turn permissions for OpenCode. `Serve` mode supplies the policy
through `OPENCODE_CONFIG_CONTENT`, which the server reads instead of the
ambient configuration, so the requested policy is the one the harness runs
under. `PermissionMode` maps onto OpenCode's `edit`/`bash` axes as
`Default`/`Custom` = ask/ask, `AcceptEdits` = allow/ask, `FullAccess` =
allow/allow and `Plan` = deny/deny. An empty
`LaunchContext::allowed_tools` becomes a `{"*": "deny"}` wildcard. A plan
turn and an empty allowlist additionally refuse any permission that reaches
the adapter without consulting the application. `Run` mode had no
enforcement an application could rely on: it accepts only `--auto` and
`--agent plan`, leaving every other policy to the machine's own
configuration.
- Turn-scoped stdio and HTTP MCP servers for OpenCode in `Serve` mode,
translated into native `local` and `remote` `mcp` entries with credentials
referenced as `{env:NAME}` rather than serialized.
`LaunchContextCapabilities` now advertises `stdio_mcp`, `http_mcp`,
`system_prompt_append` and `allowed_tools` for that mode; the latter two are
carried as a prompt prefix, which is the only channel OpenCode offers.
- `AgentAdapter::attach`, returning optional `ProtocolStreams`, lets an adapter
carry a turn on streams of its own instead of the child's stdout and stdin,
for a provider whose protocol is not on its own stdio. The frame contract is
unchanged, so `parse_line` stays one synchronous state machine and
cancellation, interrupts, interaction timeouts and line bounding are shared
by both kinds of provider. `AgentAdapter::command_for_turn` exposes the state
`prepare_turn` seeded, which now runs before the command is built.

- Bidirectional Codex support through `codex app-server`. `CodexTurnMode`
selects the transport; `Codex::app_server()` drives JSON-RPC over stdio with
live approvals (`accept`/`acceptForSession`/`decline`),
Expand Down Expand Up @@ -131,6 +165,11 @@ Versioning and Keep a Changelog conventions.
- Turn cancellation now interrupts pending approval and question handlers and
terminates the supervised provider instead of waiting for the interaction
timeout.
- A turn carried on adapter-supplied protocol streams now terminates its child
as the normal shutdown instead of waiting for an exit that never comes:
`opencode serve` is a server and does not stop because a turn ended. The turn
loop also stops reading at a terminal frame in that mode, so a carrier that
never closes its reader cannot hang a turn.

## [0.1.0] - 2026-08-31

Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ APIs, own credentials, or silently fall back to an unsandboxed process.

- Typed requests, streaming events, terminal results, and errors
- Claude Code, Codex, and OpenCode adapters behind independent Cargo features
- Live approvals with enforced per-turn permission policy: Codex through
`codex app-server`, OpenCode through `opencode serve`
- Bounded concurrent turns and bounded provider output
- Deadlines, cooperative cancellation, and process-tree cleanup
- Long-running tool processes preserved after natural turn completion by
Expand Down Expand Up @@ -167,8 +169,9 @@ For context meters and automatic/manual compaction in a durable host, read
transport and bounded host-extension helpers use fixed POSIX shell scripts;
untrusted values remain positional arguments and managed paths are derived
from validated names.
- Prompts are not logged. Claude and Codex prompts use stdin. OpenCode's current
headless CLI accepts the message as an argument, so it may be visible to local
- Prompts are not logged. Claude and Codex prompts use stdin, and OpenCode's
`Serve` turn mode posts the prompt in an HTTP body. OpenCode's headless
`Run` mode accepts the message as an argument, so it may be visible to local
process inspection; see the capability matrix.
- The runtime keeps no database and emits no telemetry. The embedding
application owns both.
Expand Down
91 changes: 77 additions & 14 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ pin and test the CLI versions used in production.
| --- | --- | --- |
| `claude` | yes | `providers::Claude` stream-JSON adapter |
| `codex` | yes | `providers::Codex` `exec --json` and `app-server` adapter |
| `opencode` | yes | `providers::OpenCode` JSON adapter |
| `opencode` | yes | `providers::OpenCode` `run --format json` and `serve` adapter |
| `nono` | yes | profile management and per-turn Nono execution |
| `tailnet` | yes | per-agent userspace Tailscale daemons and split proxy |
| `ssh` | yes | OpenSSH execution transport |
Expand All @@ -32,24 +32,24 @@ features.
| Context-window occupancy | yes, estimated from native usage components and direct after compaction | no | no |
| Configurable automatic compaction | yes | no | no |
| Provider-native manual compaction | yes, retained runtime with an existing session | no | no |
| Session identifier | yes | yes | when reported |
| Session identifier | yes | yes | yes in `Serve` mode; when reported in `Run` |
| Resume by session identifier | yes | yes | yes |
| `Default` | yes | yes, static workspace sandbox | yes, asks auto-reject in headless mode |
| `AcceptEdits` | yes | yes, static workspace sandbox | no; rejected rather than broadening access |
| `Plan` | yes | yes, read-only sandbox | yes, built-in `plan` agent |
| `FullAccess` | yes | yes | yes, `--auto`; explicit configured denies remain |
| `Default` | yes | yes, static workspace sandbox | `Serve`: `edit: ask`, `bash: ask`; `Run`: asks auto-reject in headless mode |
| `AcceptEdits` | yes | yes, static workspace sandbox | `Serve`: `edit: allow`, `bash: ask`; `Run`: rejected rather than broadening access |
| `Plan` | yes | yes, read-only sandbox | `Serve`: both categories denied; `Run`: built-in `plan` agent |
| `FullAccess` | yes | yes | `Serve`: `edit: allow`, `bash: allow`; `Run`: `--auto`, explicit configured denies remain |
| Custom mode | yes | yes, native approval policy | yes, configured agent |
| Live approvals | yes | app-server mode only (`exec` is configured non-interactively) | no (`run` is non-interactive) |
| Live user questions | yes | app-server mode only, blocking and async | no |
| Cooperative interrupt on cancellation | no | app-server mode only (`turn/interrupt`) | no |
| Structured launch context | system prompt, exact tools, stdio/HTTP MCP, strict MCP | additive stdio/HTTP MCP | rejected |
| Live approvals | yes | app-server mode only (`exec` is configured non-interactively) | `Serve` mode only (`run` is non-interactive) |
| Live user questions | yes | app-server mode only, blocking and async | no; OpenCode has no question channel |
| Cooperative interrupt on cancellation | no | app-server mode only (`turn/interrupt`) | `Serve` mode only (`session/abort`) |
| Structured launch context | system prompt, exact tools, stdio/HTTP MCP, strict MCP | additive stdio/HTTP MCP | `Serve`: system prompt and tools as a prompt prefix, stdio/HTTP MCP; `Run`: rejected |
| Native image attachments | no; described as prompt paths | yes (`--image`, `localImage` input) | no; described as prompt paths |
| Prompt kept out of argv | yes | yes | no; current `run` CLI uses message args |
| Current backend | CLI stream JSON | `codex exec --json` (default) or `codex app-server` | `opencode run --format json` |
| Current backend | CLI stream JSON | `codex exec --json` (default) or `codex app-server` | `opencode run --format json` (default) or `opencode serve` |

The public adapter trait is the extension point for OpenCode server or
SDK-backed adapters. Such adapters should preserve the normalized contract and
establish compatibility coverage before replacing a CLI adapter.
The public adapter trait is the extension point for further provider
backends. Such adapters should preserve the normalized contract and establish
compatibility coverage before replacing a CLI adapter.

### Codex turn modes

Expand Down Expand Up @@ -78,6 +78,69 @@ resumed thread identifier travel in `thread/start`/`thread/resume` and
`turn/start` instead of argv; turn-scoped stdio and HTTP MCP servers and the
model relay still use `--config` overrides.

### OpenCode turn modes

`providers::OpenCodeTurnMode` selects how an OpenCode turn runs. `Run` (the
default) keeps the one-way `opencode run --format json` behavior.

`Run` mode has no permission enforcement an application can rely on. The only
flags the CLI accepts are `--auto`, which approves everything, and
`--agent plan`; every other policy comes from whatever `opencode`
configuration exists on the machine. A caller cannot request "ask before
running a shell command", and a silently auto-refused tool call is
indistinguishable from a turn that simply produced no text.

`Serve`, selected with `OpenCode::serve()` or
`OpenCode::default().with_turn_mode(OpenCodeTurnMode::Serve)`, starts
`opencode serve` on a reserved loopback port and drives it over HTTP and
Server-Sent Events. It adds:

- a per-turn permission policy supplied through `OPENCODE_CONFIG_CONTENT`,
which the server reads *instead of* the ambient configuration, so the
requested policy is the one the harness runs under;
- live approvals for `permission.asked`, answered through
`InteractionHandler::approve` and posted to
`/session/{id}/permissions/{id}`. `ApprovalDecision::Allow`,
`ApprovalDecision::AllowForSession` and `ApprovalDecision::Deny` map to the
native `once`, `always` and `reject` responses;
- turn-scoped stdio and HTTP MCP servers, translated into OpenCode's `local`
and `remote` `mcp` entries. Credentials are referenced as `{env:NAME}` and
never serialized into the configuration;
- incremental text and reasoning deltas, tool lifecycle events, and a
cooperative `session/abort` when the turn's `CancellationToken` fires.

`PermissionMode` maps onto OpenCode's two permission axes:

| `PermissionMode` | `edit` | `bash` |
| --- | --- | --- |
| `Default`, `Custom` | `ask` | `ask` |
| `AcceptEdits` | `allow` | `ask` |
| `FullAccess` | `allow` | `allow` |
| `Plan` | `deny` | `deny` |

`Plan` denies both categories rather than selecting the planning agent.
OpenCode's read-only tools are gated by neither category, so a plan turn can
still inspect the workspace but can never have a side effect.

An empty `LaunchContext::allowed_tools` list becomes a `{"*": "deny"}`
wildcard rule, because an explicitly empty tool set has to be an enforcement
boundary rather than a suggestion in the prompt. A *non-empty* list, and
`LaunchContext::system_prompt_append`, are carried as a prompt prefix:
OpenCode has no system-prompt or tool-restriction field on its prompt body.

A plan turn and an empty tool allowlist also refuse any permission that
reaches the adapter without consulting the application. Both are already
denied by the policy the server started with, so arriving there means the
policy did not hold — and the turn promised the user that no such choice
would exist.

`Serve` mode reaches the server on the SDK host's loopback interface, so it
requires a transport that runs the provider on that host. Session resume is
validated with `session.get` rather than by listing and filtering, which
cannot fail closed on a workspace reached through a symlink; a session with a
parent is a subagent session and is rejected immediately rather than left to
hang. Reading transcripts from OpenCode's local database is not implemented.

## Private-network providers

`NetworkProviderRegistry` stores heterogeneous, trusted in-process
Expand Down
76 changes: 72 additions & 4 deletions src/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,39 @@ pub struct AdapterOutput {
pub terminal: bool,
}

/// Protocol carrier an adapter supplies in place of the provider's own stdio.
///
/// Most provider CLIs speak their protocol over stdout and stdin, so the
/// runtime reads frames from the child and writes [`AdapterOutput::writes`]
/// back to it. A provider whose protocol is *not* carried by its own stdio —
/// `opencode serve`, which exposes HTTP and Server-Sent Events on a loopback
/// port — returns these streams from [`AgentAdapter::attach`] instead.
///
/// The frame contract is deliberately unchanged: the runtime still reads
/// newline-delimited frames from [`Self::reader`] and still writes
/// newline-terminated frames to [`Self::writer`]. [`AgentAdapter::parse_line`]
/// therefore stays one synchronous, fully testable state machine no matter
/// what actually moves the bytes, and cancellation, interrupts, interaction
/// timeouts and line bounding keep working without a second code path.
///
/// The child process is still spawned, supervised and torn down by the
/// runtime. An adapter that returns streams here must keep the turn's
/// liveness tied to that child: the runtime fails the turn when the process
/// exits before a terminal frame arrives, so a server that dies mid-turn
/// surfaces immediately instead of hanging until the turn deadline.
pub struct ProtocolStreams {
/// Newline-delimited frames parsed by [`AgentAdapter::parse_line`].
pub reader: crate::TransportReader,
/// Sink for [`AdapterOutput::writes`] and encoded interaction responses.
pub writer: crate::TransportWriter,
}

impl fmt::Debug for ProtocolStreams {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.debug_struct("ProtocolStreams").finish()
}
}

/// Adapter between a provider-native CLI protocol and normalized events.
#[async_trait]
pub trait AgentAdapter: Send + Sync {
Expand Down Expand Up @@ -327,17 +360,52 @@ pub trait AgentAdapter: Send + Sync {
/// Build the provider process for one validated request.
fn command(&self, request: &TurnRequest) -> Result<CommandSpec>;

/// Build the provider process using state seeded by [`Self::prepare_turn`].
///
/// The default ignores the state and defers to [`Self::command`], which is
/// what a provider that encodes its whole turn in argv and stdin needs.
/// An adapter that must agree with itself about a value chosen per turn —
/// the loopback port `opencode serve` is told to bind and that
/// [`Self::attach`] then connects to — overrides this instead, so the
/// value is decided once in `prepare_turn` and read back here.
fn command_for_turn(&self, request: &TurnRequest, state: &AdapterState) -> Result<CommandSpec> {
let _ = state;
self.command(request)
}

/// Seed per-turn parser state from the validated request.
///
/// The runtime calls this once, after [`Self::command`] and before the
/// first output line. Adapters whose protocol issues requests of its own
/// (rather than encoding the whole turn in argv and stdin) use it to
/// retain the turn parameters that [`Self::parse_line`] later needs.
/// The runtime calls this once, before [`Self::command_for_turn`] and
/// before the first output line. Adapters whose protocol issues requests
/// of its own (rather than encoding the whole turn in argv and stdin) use
/// it to retain the turn parameters that [`Self::parse_line`] later needs.
fn prepare_turn(&self, request: &TurnRequest, state: &mut AdapterState) -> Result<()> {
let _ = (request, state);
Ok(())
}

/// Supply a protocol carrier to use instead of the child's stdout and stdin.
///
/// Called once, after the provider process is spawned and before the first
/// frame is read. Returning `None` — the default — keeps the ordinary
/// stdio contract. Returning [`ProtocolStreams`] tells the runtime to read
/// frames from, and write frames to, those streams instead; the child is
/// still spawned, supervised, stderr-drained and terminated by the runtime
/// exactly as before.
///
/// This is how a provider whose protocol lives somewhere other than its
/// own stdio joins the normal turn loop rather than growing a parallel
/// one. The implementation typically spawns a task that translates the
/// provider's native transport into newline-delimited frames.
async fn attach(
&self,
request: &TurnRequest,
state: &AdapterState,
) -> Result<Option<ProtocolStreams>> {
let _ = (request, state);
Ok(None)
}

/// Translate one stdout line and update accumulated state.
fn parse_line(&self, line: &str, state: &mut AdapterState) -> Result<AdapterOutput>;

Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ pub mod tailnet;

pub use adapter::{
AccountUsageProbeSpec, AdapterOutput, AdapterState, AgentAdapter, AuthenticationProbeSpec,
CatalogProbeSpec, CommandSpec, InteractionRequest, ProviderTerminalFailure,
CatalogProbeSpec, CommandSpec, InteractionRequest, ProtocolStreams, ProviderTerminalFailure,
};
pub use chat::{
Chat, ChatApproval, ChatAttachment, ChatCommit, ChatEvent, ChatEventData, ChatMessage,
Expand Down
6 changes: 5 additions & 1 deletion src/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,17 @@ mod codex;
mod codex_app_server;
#[cfg(feature = "opencode")]
mod opencode;
#[cfg(feature = "opencode")]
mod opencode_http;
#[cfg(feature = "opencode")]
mod opencode_serve;

#[cfg(feature = "claude")]
pub use claude::Claude;
#[cfg(feature = "codex")]
pub use codex::{Codex, CodexTurnMode};
#[cfg(feature = "opencode")]
pub use opencode::OpenCode;
pub use opencode::{OpenCode, OpenCodeTurnMode};

#[cfg(any(feature = "claude", feature = "codex"))]
use serde_json::Value;
Expand Down
Loading
Loading