diff --git a/README.md b/README.md
index bdfe90b..cd49f5b 100644
--- a/README.md
+++ b/README.md
@@ -9,14 +9,16 @@
+
-**A Python SDK for building agents on Claude Code and Codex.**
+**A Python SDK for building agents on Claude Code, Codex, and OpenCode.**
-Claude Code and Codex have become general-purpose agents: give them
-instructions, skills, and subagents, and they can be shaped to any task. Yoke
-lets you reuse them from code — one `Harness` that drives both.
+Claude Code, Codex, and OpenCode have become general-purpose agents: give
+them instructions, skills, and subagents, and they can be shaped to any
+task. Yoke lets you reuse them from code — one `Harness` that drives all
+three.
Quickstart ·
@@ -42,6 +44,9 @@ Install a provider extra when you want Yoke to manage that SDK directly:
pip install 'almanac-yoke[claude]' # or [codex], or [all]
```
+OpenCode needs no extra — it has no Python SDK to install; Yoke drives it
+by spawning `opencode serve` and talking to its HTTP API directly.
+
Define an agent, pick a harness, run:
```python
@@ -60,7 +65,9 @@ print(result.output)
```
Swap `"codex"` for `"claude"` and the same agent runs there. Your existing
-Claude Code or ChatGPT login is all it needs — no API keys.
+Claude Code or ChatGPT login is all it needs — no API keys. Swap it for
+`"opencode"` and Yoke drives whatever provider your `opencode auth login`
+(or a configured API key) already has set up.
Embedding applications can observe a one-shot run while it is happening:
@@ -258,6 +265,7 @@ require an exact one:
| `codex:sdk` | Codex Python SDK |
| `codex:cli` | Codex CLI — `codex exec`, resumable threads |
| `claude:sdk` | Claude Agent SDK for Python |
+| `opencode:server` | OpenCode's local HTTP server — sessions, fork, native skills |
`discover` reports what this machine already has — surfaces installed, logins
ready, models available — and picks the first ready surface satisfying the
diff --git a/almanac/concepts/provider-surfaces.md b/almanac/concepts/provider-surfaces.md
index d5e8a90..fab3f7a 100644
--- a/almanac/concepts/provider-surfaces.md
+++ b/almanac/concepts/provider-surfaces.md
@@ -21,6 +21,24 @@ sources:
- id: capability-tests
type: file
path: tests/test_capabilities.py
+ - id: opencode-server
+ type: file
+ path: src/yoke/providers/opencode_server.py
+ - id: opencode-plan
+ type: file
+ path: docs/plans/2026-07-11-opencode-provider.md
+ - id: opencode-permissions
+ type: file
+ path: src/yoke/providers/opencode/permissions.py
+ - id: opencode-hooks
+ type: file
+ path: src/yoke/providers/opencode/hooks.py
+ - id: opencode-agents
+ type: file
+ path: src/yoke/providers/opencode/agents.py
+ - id: options
+ type: file
+ path: src/yoke/options.py
---
Provider surfaces are the concrete Claude or Codex entrypoints that Yoke plans against. A provider is the family, such as `codex` or `claude`; a surface is the actual exposure path, such as `codex_app_server`, `codex_python_sdk`, `codex_cli`, or `claude_python_sdk` [@models]. Surfaces matter because Yoke treats features as surface-specific, not provider-wide [@decision].
@@ -52,3 +70,27 @@ Run event callbacks show the planning rule in a small form. `RunOptions(on_event
Provider surfaces are not a cosmetic naming scheme. They control whether a [Yoke Harness](yoke-harness) may run, stream, resume, fork, use workflows, expose request callbacks, or report a feature as unavailable. The capability matrix answers what the surface can do before the provider turn starts [@surfaces].
This is why the decision note calls provider surfaces first-class. A generic provider default is acceptable for simple use, but advanced behavior must resolve to a concrete surface before Yoke claims that a feature exists [@decision].
+
+## OpenCode: a third provider with no Python SDK
+
+`opencode_server` is Yoke's third provider surface, alongside Claude and Codex [@models]. Unlike either of those, OpenCode ships no Python SDK — the adapter spawns `opencode serve --port 0` as a child process and drives it entirely over its documented HTTP API [@opencode-server]. That makes it closer in shape to `codex_app_server` (a locally spawned, long-lived process) than to a Python-SDK surface, and it reuses the same design precedent: the process/HTTP/polling mechanics stay synchronous and thread-backed, and the async `ProviderAdapter` methods bridge to them with `asyncio.to_thread` rather than a from-scratch asyncio rewrite [@opencode-server].
+
+OpenCode's own SSE event stream was found unreliable for live progress narration in a prior live spike, so this adapter instead polls OpenCode's own SQLite database for new session parts while a turn is in flight, and uses the same polling loop to detect a tool call stuck past a threshold — a confirmed upstream OpenCode reliability gap, not a Yoke bug [@opencode-plan]. SQLite is only used for this in-flight progress poll; stored session history (`read_session()`) instead calls the documented `GET /session/:id/message` endpoint, sliced locally for offset/limit since OpenCode's own `limit` keeps the most recent N messages rather than the earliest N [@opencode-server].
+
+Sessions are first-class on this surface, not a one-shot-only wrapper — OpenCode's HTTP API supports real `GET /session`, `PATCH /session/:id`, `POST /session/:id/fork`, and `POST /session/:id/summarize` endpoints, so `start`/`send`/`close` map onto genuine multi-turn sessions and `run()` is a thin convenience wrapper over them [@opencode-server]. A forked session shares its parent's underlying server process rather than spawning a new one, so process termination is reference-counted the same way `CodexAppServer` reference-counts its shared app-server process, rather than tied to whichever session happens to close first [@opencode-server]. Confirmed live: `POST /session/:id/fork` does not inherit the parent's permission ruleset — a fork starts with none at all (default allow) regardless of how restrictive the parent session was — so `fork()` re-applies it via `PATCH /session/:id` right after forking [@opencode-server].
+
+### Skills, subagents, and MCP: config-file compilation
+
+OpenCode discovers skills, custom agents, and MCP servers from files and config, not a runtime registration API, so this adapter compiles Yoke's declarative model into that shape once per session rather than issuing calls during a turn. Inline skills and direct Yoke subagents render as `skills//SKILL.md` and `agents/.md` (YAML frontmatter, `mode: subagent`) under a Yoke-owned deployment directory pointed at by `OPENCODE_CONFIG_DIR`, so nothing lands in the user's real project [@opencode-agents] [@opencode-server]. Direct subagents only — OpenCode documents no nested subagent-of-subagent invocation model to compile a recursive one against [@opencode-agents]. `agent.options["mcp_servers"]` renders as `{"mcp": {...}}` JSON passed through `OPENCODE_CONFIG_CONTENT`, OpenCode's highest-precedence config source, since there is no runtime add-server endpoint to call instead [@opencode-server].
+
+Each subagent's own `permissions.access`/`.network` also compiles into a `permission:` frontmatter key on its `agents/.md` file — confirmed live that a per-agent `bash: deny` genuinely blocks the tool for that agent specifically, independent of whatever the parent session's own ruleset allows. This mirrors `codex_agent_toml()`'s existing `sandbox_mode(agent.permissions)` translation for Codex subagents; a subagent left at the default `Permissions()` (access=READ) already got Codex's read-only sandbox, so OpenCode's default-permissions subagents now get the equivalent restriction rather than inheriting an unrestricted session [@opencode-agents].
+
+### Live permission approval: polling, not SSE, and not the endpoint you'd expect
+
+The original plan assumed pending permissions were "only learnable via SSE" and shipped an always-allow-all session, declaring `PERMISSIONS`/`REQUEST_EVENTS` `compiled`/`unsupported` [@opencode-plan]. That assumption was wrong: `GET /permission` is a real, non-deprecated, polling-discoverable endpoint listing every pending permission across sessions, confirmed live against a real `opencode serve` process — the same poll-not-SSE shape as the DB-poll progress watchdog, just polling OpenCode's HTTP API instead of its SQLite database [@opencode-permissions]. `Permissions.approval=ASK` now passes an ask-all session permission block instead of allow-all, and a dedicated `OpencodePermissionWatchdog` runs on its own thread alongside the progress watchdog, resolving each pending permission through `ProviderOptions.opencode.request_handler`/`.policy` — the same `RequestPolicy`/`Response` contract Claude and Codex app-server already use for their own request callbacks — and replying via `POST /permission/:id/reply` [@opencode-permissions] [@options]. The endpoint this adapter used to target for replies, `/session/:id/permissions/:permissionID`, turned out to be deprecated in OpenCode's own OpenAPI document; the reply now goes through the current one [@opencode-permissions].
+
+`Permissions.access`/`.network` are translated the same way `accessible_claude_tools()` gates Claude's own tool list: write/edit/apply_patch need WRITE or FULL, bash needs FULL, webfetch/websearch need `network`. An earlier version of this session-permission block only translated `approval`, so `Permissions(access=READ, network=False, approval=NEVER)` produced a bare `* = allow` rule and every tool actually ran despite the session reporting a read-only, no-network posture — confirmed live and fixed [@opencode-server].
+
+### Plugins and hooks: a generated bridge, not just config
+
+Plugins are OpenCode's one genuinely executable extension point (JS/TS auto-loaded from the same `OPENCODE_CONFIG_DIR` used for skills/agents), which splits into two very different features here. `agent.options["opencode_plugins"]` (name → raw JS source) is pure pass-through — Yoke writes whatever the caller supplies into `plugin/.js` and does not generate or validate it, the same shape as MCP config [@opencode-server]. Hooks are the opposite: Yoke *generates* a `tool.execute.before` plugin that relays every tool call to a small local HTTP server this adapter starts (`OpencodeHookBridge`), which resolves the call through the same `request_handler`/`.policy` contract permissions use and replies with a decision [@opencode-hooks]. Confirmed live: mutating the reply's `args` actually changes the command OpenCode executes, and denying actually blocks the tool call with a graceful message back to the model — not just an observed-after-the-fact event [@opencode-hooks]. The bridge is opt-in (no configured handler means no plugin file and no server) and process-scoped rather than session-scoped: it's addressed by an env var fixed at `opencode serve` spawn time, and a fork shares its parent's process and env, so one bridge serves every session on that process, routing by the `sessionID` present in each tool call's own payload [@opencode-hooks].
diff --git a/docs/plans/2026-07-11-opencode-provider.md b/docs/plans/2026-07-11-opencode-provider.md
new file mode 100644
index 0000000..294f517
--- /dev/null
+++ b/docs/plans/2026-07-11-opencode-provider.md
@@ -0,0 +1,81 @@
+# OpenCode Provider Implementation Plan
+
+**Status (2026-07-12):** Task 4's PERMISSIONS/REQUEST_EVENTS row and the "no
+polling-discoverable pending permission signal" claim below turned out to be
+wrong — `GET /permission` is real and non-deprecated. Live permission
+approval, filesystem-agent subagents, and both PLUGINS and HOOKS (all listed
+`UNSUPPORTED`/`UNKNOWN` below) shipped after this plan was written. This
+document is kept as the historical record of the initial implementation;
+see [Provider Surfaces](../../almanac/concepts/provider-surfaces.md) and
+`src/yoke/surfaces.py` for current, accurate capability status.
+
+**Goal:** Add OpenCode as Yoke's third provider, so any Yoke consumer (CodeAlmanac included) can run agents on OpenCode through the same `Harness` API as Claude and Codex.
+
+**Architecture:** `Provider.OPENCODE` / `Surface.OPENCODE_SERVER`. OpenCode has no Python SDK — it's a locally spawned HTTP server (`opencode serve --port 0`) with a documented OpenAPI surface. Following the precedent already set by `CodexAppServer` (`providers/codex_app/process.py`), the process/HTTP/DB-polling mechanics stay synchronous and thread-backed — the same shape as the proven CodeAlmanac implementation being ported — and the async `ProviderAdapter` methods bridge to them via `asyncio.to_thread`, rather than a ground-up asyncio rewrite that would need fresh live-testing to trust. OpenCode's own SSE stream was found unreliable for live progress in a prior spike (CodeAlmanac, 2026-07-09), so this adapter polls OpenCode's own SQLite database for live events and stuck-tool-call detection instead. Sessions are first-class (`start`/`send`/`close`), not a one-shot-only wrapper: OpenCode's HTTP API supports session reuse natively, and Yoke shouldn't be narrower than the surface it wraps.
+
+**Tech Stack:** Python 3.11+, httpx (sync, bridged via `asyncio.to_thread`), stdlib `sqlite3`, pytest/pytest-asyncio.
+
+**Evidence base:** https://opencode.ai/docs/server/ (session/auth/mcp/permission endpoints), https://opencode.ai/docs/skills/ (native `.opencode/skills/*/SKILL.md`, also reads `.claude/skills/`), https://opencode.ai/docs/mcp-servers/ (config-file only, no runtime API), https://opencode.ai/docs/providers/, https://opencode.ai/docs/cli/. Domain logic (server spawn, part-mapping, stuck-tool-call heuristics) ported from CodeAlmanac's `integrations/harnesses/opencode/*` (pre-migration), which spiked and validated these HTTP behaviors live.
+
+---
+
+### Task 1: Provider/Surface plumbing
+
+- Add `Provider.OPENCODE = "opencode"` and `Surface.OPENCODE_SERVER = "opencode_server"` to `models.py`.
+- Register in `adapters.py`: `_builtin_surfaces["opencode"] = {"opencode_server"}`, `default_adapter()` branch.
+- `surfaces.py`: `default_surface()`, `SURFACE_CHANNELS` (`Channel.APP_SERVER` — same shape as `codex_app_server`, a locally spawned long-lived process, not a CLI one-shot or SDK import), `SURFACE_RUNTIMES` (`"opencode_server"`), `SURFACE_EVIDENCE` citing the docs above.
+- Add failing tests asserting `adapter_for("opencode")` resolves and `has_adapter("opencode", "opencode_server")` is true before any adapter exists (`test_provider_surface.py` pattern).
+
+### Task 2: Async process + HTTP mechanics
+
+- `providers/opencode/process.py`: `asyncio.create_subprocess_exec("opencode", "serve", "--port", "0", ...)`, async stdout scan for `listening on http://127.0.0.1:(\d+)` with `asyncio.wait_for` deadline (direct async port of the existing `_wait_for_listening`). Windows note carried over: resolve via `shutil.which` first.
+- `providers/opencode/http.py`: `httpx.AsyncClient` wrapping `GET /config/providers`, `POST /session`, `GET /session`, `GET /session/:id`, `PATCH /session/:id`, `DELETE /session/:id`, `POST /session/:id/fork`, `POST /session/:id/abort`, `POST /session/:id/summarize`, `POST /session/:id/message`, `POST /session/:id/permissions/:permissionID`, `PUT /auth/:id`, `GET /agent`.
+- `providers/opencode/db.py` (new — Yoke can't import CodeAlmanac's `query_readonly_or_empty`): stdlib `sqlite3.connect(f"file:{path}?mode=ro", uri=True)` wrapped in `asyncio.to_thread`, tolerating missing file/table/corrupt db by returning `()`.
+- Tests: fake `opencode serve` (a tiny stub script) or a `respx`/`httpx.MockTransport`-backed fake server, matching `test_codex_app_server_params.py`'s fixture style.
+
+### Task 3: Event normalization + progress polling
+
+- `providers/opencode/parts.py`: port `map_opencode_part` (text/reasoning/tool/patch/step-finish) to yield Yoke `Event` objects — `EventKind.TEXT`, `TOOL_USE`/`TOOL_RESULT` (with `Tool`/`tool_is_error`), `TOOL_SUMMARY` (reasoning + patch), `CONTEXT_USAGE` (step-finish → `Usage`). Task-tool spawns become `Event(kind=TOOL_USE, agent=AgentCall(agent_type="task", new_thread_id=child_session_id, prompt=...))` — Yoke has no dedicated agent-spawn event kind, it rides the existing `agent` field.
+- `providers/opencode/progress.py`: `asyncio.Task` polling loop (replaces the thread + `queue.Queue` + `threading.Event` original) reading `part`/`message` rows scoped to known session ids, discovering child sessions via the `task` tool the same way, and raising a stuck-tool-call error past `stuck_after_seconds` (default 240s — this is a confirmed upstream OpenCode reliability gap, not a Yoke bug; cite the same tracking issue in the docstring).
+- The send-vs-watchdog race stays the original's thread-join-with-timeout loop (send on one thread, watchdog on another, main thread polls `watchdog.stuck_reason` while joining with a short timeout) — this is synchronous code bridged via `asyncio.to_thread` at the adapter boundary, not native asyncio tasks.
+- Tests: feed a fake sqlite db with part/message rows across poll cycles, assert event ordering, child-session discovery, and stuck detection at the threshold.
+
+### Task 4: `OpencodeServer` adapter (`providers/opencode_server.py`)
+
+- Implement `ProviderAdapter`: `check` (brief server + `GET /config/providers`, empty list → not ready), `models` (native, from providers list), `start`/`send`/`close` as the primary path, `run` as `start → send → close`.
+- `list_sessions`/`read_session`/`rename`/`fork`/`interrupt`/`compact` map directly to the endpoints in Task 2 — these are real, not emulated, per the OpenAPI surface.
+- `login(method="api_key", api_key=...)` → `PUT /auth/:id`. OAuth authorize/callback exists in the API but is out of scope for this task (no interactive browser flow in this adapter yet) — declare it explicitly rather than silently doing nothing.
+- Permissions: session creation always passes the allow-all block. `POST /session/:id/permissions/:permissionID` exists to *answer* a request, but there is no polling-discoverable way to learn a permission is *pending* (only SSE, which this adapter deliberately avoids) — do not build an approval-callback loop this pass; see the corrected capability table.
+- Skills: render Yoke skills as `SKILL.md` files under a Yoke-owned deployment directory, and set `OPENCODE_CONFIG_DIR` (per https://opencode.ai/docs/config/) to point OpenCode at it — this reuses `native_skills.py` rendering but needs its own deployment wiring (`runtime_deployment.py` currently branches `if provider is Provider.CODEX else _write_claude(...)` — a binary dispatch that will silently write Claude-shaped files for OpenCode unless corrected to an exhaustive branch alongside a new `_write_opencode`. `runtime_owner_pid()`'s stale-deployment reclaim also hardcodes `{Provider.CLAUDE.value, Provider.CODEX.value}` and needs `Provider.OPENCODE.value` added, or opencode runtime dirs never get reclaimed).
+- Declared subagents: `Support.COMPILED` — lower Yoke subagents into whatever file shape `GET /agent` reads from (check `opencode` source/docs for the exact file format before implementing; if undocumented, mark `Support.UNKNOWN` with a note rather than guessing the shape).
+- Failure classification ported from `failures.py` (not_installed, server_start_failed, stuck_tool_call, timeout, generic).
+
+### Task 5: Capability matrix + docs
+
+- `surfaces.py` `MATRIX[(Provider.OPENCODE, Surface.OPENCODE_SERVER)]` per the table below, plus `FEATURE_EVIDENCE`/`FEATURE_LOWERING`/`FEATURE_RECIPES` entries citing the URLs above. Done.
+- Almanac docs: no existing per-provider pages exist for Claude/Codex either (provider specifics already live inside `almanac/concepts/provider-surfaces.md`), so a new standalone `opencode.md` page would break that established pattern rather than follow it. Added an "OpenCode: a third provider with no Python SDK" section to `provider-surfaces.md` instead, documenting the DB-polling decision, why SSE was rejected, why permissions are compiled not native, and the shared-process/fork reference-counting design. Done.
+- `pyproject.toml`: confirmed no new optional dependency needed — `httpx` is already a base dependency, not extras-gated, and no OpenCode Python SDK exists to depend on.
+- `docs/reference.md`: added the `opencode_server` row to the surfaces/capabilities table. Done.
+- `README.md`: the tagline ("A Python SDK for building agents on Claude Code and Codex") and intro paragraph became inaccurate once this lands — updated tagline, intro, badge row, quickstart install-extras note (OpenCode needs none), the `"codex"`/`"claude"` swap paragraph, and the surfaces table to include OpenCode. Left the "How it compares" table's Claude Agent SDK/Codex SDK row unchanged — it specifically compares official vendor SDKs, and OpenCode has no equivalent official SDK to list there; editing it would make it less accurate, not more. Done.
+
+| Feature | Support | Note |
+|---|---|---|
+| SESSION / SESSION_LIST / SESSION_READ / SESSION_RENAME / SESSION_COMPACT / FORK / INTERRUPT | NATIVE | direct endpoint per Task 4 |
+| MODELS | NATIVE | `GET /config/providers` |
+| LOGIN | NATIVE (api_key only) | `PUT /auth/:id`; OAuth path unimplemented, noted |
+| PERMISSIONS | COMPILED | blanket allow/deny set at session creation only — see below |
+| REQUEST_EVENTS / REQUEST_CALLBACKS | UNSUPPORTED | corrected during implementation: `POST /session/:id/permissions/:permissionID` can *answer* a pending request, but there is no polling-discoverable "pending permission" signal — OpenCode's docs indicate this is only learnable via SSE, and this adapter deliberately does not depend on SSE (see Task 3). Revisit if SSE reliability for this one low-volume signal is separately confirmed. |
+| SKILLS | NATIVE | `OPENCODE_CONFIG_DIR` env var points OpenCode at a Yoke-generated skill directory (`skills//SKILL.md`) without touching the user's project — confirmed via https://opencode.ai/docs/config/. Reuses `native_skills.py` rendering. |
+| INLINE_SUBAGENTS | NATIVE | `task` tool spawns, confirmed by CodeAlmanac's spike |
+| DECLARED_SUBAGENTS | COMPILED or UNKNOWN | pending file-format confirmation, Task 4 |
+| STREAMING / RUN_EVENT_CALLBACKS | EMULATED | DB-poll based; native SSE spiked unreliable |
+| MCP | COMPILED | config-file only (`OPENCODE_CONFIG_CONTENT` env var), no runtime add-server API |
+| SESSION_TAG, GOAL, GOAL_LOOP, MUTABLE_GOAL, READABLE_GOAL, NATIVE_WORKFLOW, STRUCTURED_OUTPUT, COLLAB_AGENT_TOOLS, COLLABORATION_MODE, HOOKS, PLUGINS | UNSUPPORTED | no endpoint/evidence found |
+
+**Permissions default:** session creation always passes the allow-all permission block (`OPENCODE_ALLOW_ALL_PERMISSION`), matching CodeAlmanac's proven behavior. `RunOptions.permissions`/`SessionOptions.permissions` with a non-default `Approval` can be lowered to a narrower session-creation-time permission block, but there is no live interactive approval loop this pass.
+
+### Task 6: Verify
+
+- Full `pytest` + `ruff check .`.
+- Live lab: readiness check, one-shot run producing text + tool use, a multi-turn session (start/send/send/close), a forced permission-approval round-trip, and a deliberately long-running tool call to confirm stuck-detection fires (or a mocked-timing test if 240s is impractical live).
+- Update `docs/reference.md` provider table.
diff --git a/docs/reference.md b/docs/reference.md
index 72cfaf7..aa68b2d 100644
--- a/docs/reference.md
+++ b/docs/reference.md
@@ -1545,6 +1545,7 @@ Yoke models capabilities by surface, not only by provider.
| Codex | `codex_python_sdk` | `sdk` | `codex_app_server` | published Python SDK, app-server-backed automation surface |
| Codex | `codex_typescript_sdk` | `sdk` | `codex_sdk` | documented TypeScript SDK over local Codex agents; tracked, no built-in adapter yet |
| Codex | `codex_app_server` | `app_server` | `codex_app_server` | live app protocol, streaming, skill roots, plugins, goals, collab agent events |
+| OpenCode | `opencode_server` | `app_server` | `opencode_server` | locally spawned HTTP server, real sessions/fork/rename/compact, native skills, poll-based live events |
`Surface` intentionally names documented entrypoints even before Yoke ships a
built-in adapter for each one. That keeps design discussions precise: a feature
diff --git a/src/yoke/adapters.py b/src/yoke/adapters.py
index cd99123..9b8c8db 100644
--- a/src/yoke/adapters.py
+++ b/src/yoke/adapters.py
@@ -13,6 +13,7 @@
_builtin_surfaces: dict[ProviderKey, set[str]] = {
"claude": {"claude_python_sdk"},
"codex": {"codex_cli", "codex_python_sdk", "codex_app_server"},
+ "opencode": {"opencode_server"},
}
@@ -102,6 +103,15 @@ def default_adapter(provider: Provider, surface: str | None = None) -> ProviderA
raise AdapterNotFound(
f"no built-in Yoke adapter for provider {provider!r} surface {surface!r}"
)
+ if provider_key == "opencode":
+ if surface_key not in (None, "opencode_server"):
+ raise AdapterNotFound(
+ "no built-in Yoke adapter for provider "
+ f"{provider!r} surface {surface!r}"
+ )
+ from yoke.providers.opencode_server import OpencodeServer
+
+ return OpencodeServer()
raise AdapterNotFound(f"no built-in Yoke adapter for provider {provider!r}")
diff --git a/src/yoke/models.py b/src/yoke/models.py
index abb5d34..25f2006 100644
--- a/src/yoke/models.py
+++ b/src/yoke/models.py
@@ -61,6 +61,7 @@ class Provider(StrEnum):
CLAUDE = "claude"
CODEX = "codex"
+ OPENCODE = "opencode"
class Surface(StrEnum):
@@ -73,6 +74,7 @@ class Surface(StrEnum):
CODEX_PYTHON_SDK = "codex_python_sdk"
CODEX_TYPESCRIPT_SDK = "codex_typescript_sdk"
CODEX_APP_SERVER = "codex_app_server"
+ OPENCODE_SERVER = "opencode_server"
class Channel(StrEnum):
@@ -137,6 +139,12 @@ def normalize_provider_surface(provider: object, surface: object) -> object:
"typescript": Surface.CODEX_TYPESCRIPT_SDK,
"typescript_sdk": Surface.CODEX_TYPESCRIPT_SDK,
},
+ Provider.OPENCODE: {
+ "server": Surface.OPENCODE_SERVER,
+ "app": Surface.OPENCODE_SERVER,
+ "app_server": Surface.OPENCODE_SERVER,
+ "http": Surface.OPENCODE_SERVER,
+ },
}
return aliases[provider_value].get(surface_key, surface_text)
@@ -410,6 +418,7 @@ def require_message(cls, value: str) -> str:
def normalize_known_surface(cls, value: object) -> object:
return normalize_surface(value)
+
class Login(YokeModel):
"""Provider login flow result."""
@@ -2401,11 +2410,7 @@ def surface_plan(
and channel_value is not None
and profile.channel is not channel_value
)
- if (
- surface is None
- and required
- and required != (Feature.GOAL,)
- ):
+ if surface is None and required and required != (Feature.GOAL,):
profile = select_profile(
provider,
requires=required,
diff --git a/src/yoke/options.py b/src/yoke/options.py
index 28c966d..157f942 100644
--- a/src/yoke/options.py
+++ b/src/yoke/options.py
@@ -800,6 +800,64 @@ def capabilities(self, *, experimental_api: bool = False) -> dict[str, Any]:
return capabilities
+class OpencodeOptions(YokeModel):
+ """Typed OpenCode options plus raw escape hatch.
+
+ `policy`/`request_handler` answer permissions OpenCode's session marked
+ "ask" (see Permissions.approval) — discovered live via GET /permission
+ and answered via POST /permission/:id/reply, both poll-based like the
+ rest of this adapter, not SSE-based.
+ """
+
+ policy: RequestPolicy | dict[str, Any] | None = None
+ request_handler: Any | None = Field(
+ default=None,
+ exclude=True,
+ json_schema_extra={
+ "runtime_only": True,
+ "reason": (
+ "Callbacks are live Python objects. Configure request_handler "
+ "in SDK code, not in a Yoke folder."
+ ),
+ },
+ )
+ raw: dict[str, Any] = Field(default_factory=dict)
+
+ def runtime_options(self) -> tuple[RuntimeOption, ...]:
+ """Return active SDK-only fields that will not round-trip through folders."""
+
+ return runtime_options(self)
+
+ def features(self) -> tuple[Feature, ...]:
+ """Return provider features implied by OpenCode options."""
+
+ from yoke.capabilities import Feature
+
+ features: list[Feature] = []
+ if self.request_handler is not None or self.policy is not None:
+ features.append(Feature.REQUEST_EVENTS)
+ if self.raw.get("request_handler") is not None:
+ features.append(Feature.REQUEST_EVENTS)
+ if self.raw.get("requestHandler") is not None:
+ features.append(Feature.REQUEST_EVENTS)
+ if self.raw.get("policy") is not None:
+ features.append(Feature.REQUEST_EVENTS)
+ deduped: list[Feature] = []
+ extend_unique(deduped, tuple(features))
+ return tuple(deduped)
+
+
+def opencode_request_handler(options: OpencodeOptions | dict[str, Any] | None) -> Any:
+ """Return the caller's OpenCode permission handler, typed or raw."""
+
+ if isinstance(options, OpencodeOptions):
+ return options.request_handler or options.policy
+ if isinstance(options, dict):
+ handler = options.get("request_handler") or options.get("requestHandler")
+ return handler if handler is not None else options.get("policy")
+ return None
+
+
class ProviderOptions(YokeModel):
"""Provider-specific options.
@@ -809,6 +867,9 @@ class ProviderOptions(YokeModel):
claude: ClaudeOptions | dict[str, Any] = Field(default_factory=ClaudeOptions)
codex: CodexOptions | dict[str, Any] = Field(default_factory=CodexOptions)
+ opencode: OpencodeOptions | dict[str, Any] = Field(
+ default_factory=OpencodeOptions
+ )
def runtime_options(self) -> tuple[RuntimeOption, ...]:
"""Return active SDK-only fields that will not round-trip through folders."""
@@ -823,6 +884,10 @@ def features(self, *, provider: object | None = None) -> tuple[Feature, ...]:
extend_unique(features, provider_option_features(self.claude, "claude"))
if provider in (None, "codex"):
extend_unique(features, provider_option_features(self.codex, "codex"))
+ if provider in (None, "opencode"):
+ extend_unique(
+ features, provider_option_features(self.opencode, "opencode")
+ )
return tuple(features)
@@ -831,10 +896,24 @@ def provider_option_features(options: object, provider: str) -> tuple[Feature, .
from yoke.capabilities import Feature
- if isinstance(options, (ClaudeOptions, CodexOptions)):
+ if isinstance(options, (ClaudeOptions, CodexOptions, OpencodeOptions)):
return options.features()
if not isinstance(options, dict):
return ()
+ if provider == "opencode":
+ features: list[Feature] = []
+ if options.get("request_handler") is not None:
+ features.append(Feature.REQUEST_EVENTS)
+ if options.get("requestHandler") is not None:
+ features.append(Feature.REQUEST_EVENTS)
+ if options.get("policy") is not None:
+ features.append(Feature.REQUEST_EVENTS)
+ raw = options.get("raw")
+ if isinstance(raw, dict):
+ extend_unique(features, provider_option_features(raw, provider))
+ deduped: list[Feature] = []
+ extend_unique(deduped, tuple(features))
+ return tuple(deduped)
if provider == "claude":
features: list[Feature] = []
if options.get("permission_mode") is not None:
diff --git a/src/yoke/providers/_fields.py b/src/yoke/providers/_fields.py
new file mode 100644
index 0000000..1312bc9
--- /dev/null
+++ b/src/yoke/providers/_fields.py
@@ -0,0 +1,44 @@
+"""Loose JSON field helpers shared by providers with no typed SDK payloads.
+
+Codex app-server and OpenCode both talk to their provider over JSON (JSON-RPC
+and HTTP respectively) rather than a typed Python SDK, so both need the same
+small set of "read this field defensively" helpers. Kept here once so a fix
+to one provider's field parsing doesn't quietly diverge from the other's.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import TypeGuard
+
+from pydantic import JsonValue
+
+JsonObject = dict[str, JsonValue]
+
+
+def as_record(value: JsonValue | None) -> JsonObject:
+ if is_record(value):
+ return value
+ return {}
+
+
+def is_record(value: JsonValue | None) -> TypeGuard[JsonObject]:
+ return isinstance(value, dict)
+
+
+def string_field(record: Mapping[str, JsonValue], field: str) -> str | None:
+ value = record.get(field)
+ if isinstance(value, str) and value != "":
+ return value
+ return None
+
+
+def number_field(record: Mapping[str, JsonValue], field: str) -> int | None:
+ value = record.get(field)
+ if isinstance(value, bool):
+ return None
+ if isinstance(value, int):
+ return value
+ if isinstance(value, float) and value.is_integer():
+ return int(value)
+ return None
diff --git a/src/yoke/providers/codex_app/fields.py b/src/yoke/providers/codex_app/fields.py
index 98358ba..3ea1c4a 100644
--- a/src/yoke/providers/codex_app/fields.py
+++ b/src/yoke/providers/codex_app/fields.py
@@ -3,41 +3,29 @@
from __future__ import annotations
import json
-from collections.abc import Mapping
-from typing import TypeGuard, TypeVar
+from typing import TypeVar
from pydantic import JsonValue
-T = TypeVar("T")
-JsonObject = dict[str, JsonValue]
-
-
-def as_record(value: JsonValue | None) -> JsonObject:
- if is_record(value):
- return value
- return {}
-
-
-def is_record(value: JsonValue | None) -> TypeGuard[JsonObject]:
- return isinstance(value, dict)
-
+from yoke.providers._fields import (
+ JsonObject,
+ as_record,
+ is_record,
+ number_field,
+ string_field,
+)
+
+__all__ = [
+ "JsonObject",
+ "as_record",
+ "compact_json",
+ "first_present",
+ "is_record",
+ "number_field",
+ "string_field",
+]
-def string_field(record: Mapping[str, JsonValue], field: str) -> str | None:
- value = record.get(field)
- if isinstance(value, str) and value != "":
- return value
- return None
-
-
-def number_field(record: Mapping[str, JsonValue], field: str) -> int | None:
- value = record.get(field)
- if isinstance(value, bool):
- return None
- if isinstance(value, int):
- return value
- if isinstance(value, float) and value.is_integer():
- return int(value)
- return None
+T = TypeVar("T")
def compact_json(value: JsonValue) -> str:
diff --git a/src/yoke/providers/opencode/__init__.py b/src/yoke/providers/opencode/__init__.py
new file mode 100644
index 0000000..fed5201
--- /dev/null
+++ b/src/yoke/providers/opencode/__init__.py
@@ -0,0 +1 @@
+"""OpenCode provider mechanics: process, HTTP, DB polling, event mapping."""
diff --git a/src/yoke/providers/opencode/agents.py b/src/yoke/providers/opencode/agents.py
new file mode 100644
index 0000000..4d5682d
--- /dev/null
+++ b/src/yoke/providers/opencode/agents.py
@@ -0,0 +1,102 @@
+"""OpenCode filesystem agent (custom subagent) rendering.
+
+OpenCode discovers subagents from markdown files with YAML frontmatter —
+confirmed at https://opencode.ai/docs/agents/: a global
+`~/.config/opencode/agents/*.md` or per-project `.opencode/agents/*.md`
+directory, filename becomes the agent id, `mode: subagent` marks it
+invokable via `@mention` or auto-delegation rather than as a primary agent.
+Scoped to direct Yoke subagents only (mirrors `codex_agent_files`'s scope) —
+OpenCode's docs describe no nested subagent-of-subagent invocation model to
+compile against.
+"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+from pathlib import Path
+
+from yoke.models import Access, Agent
+from yoke.providers.codex_agents import description_for, instructions_for, slug
+
+
+@dataclass(slots=True)
+class OpencodeAgentFile:
+ """One `.opencode/agents/*.md` file Yoke can materialize explicitly."""
+
+ name: str
+ path: Path
+ text: str
+
+
+def opencode_agent_files(
+ agent: Agent,
+ *,
+ directory: str | Path = ".opencode/agents",
+) -> tuple[OpencodeAgentFile, ...]:
+ """Compile direct Yoke subagents to OpenCode custom-agent markdown files."""
+
+ root = Path(directory)
+ return tuple(
+ OpencodeAgentFile(
+ name=name,
+ path=root / f"{slug(name)}.md",
+ text=opencode_agent_markdown(name, subagent),
+ )
+ for name, subagent in agent.subagents.items()
+ )
+
+
+def opencode_agent_markdown(name: str, agent: Agent) -> str:
+ """Render one OpenCode custom-agent markdown document."""
+
+ lines = [
+ "---",
+ f"description: {yaml_string(description_for(name, agent))}",
+ "mode: subagent",
+ ]
+ if agent.model:
+ lines.append(f"model: {yaml_string(agent.model)}")
+ permission = _agent_permission_config(agent)
+ if permission:
+ lines.append("permission:")
+ for tool, action in permission.items():
+ lines.append(f" {tool}: {action}")
+ lines.extend(["---", "", instructions_for(agent)])
+ return "\n".join(lines) + "\n"
+
+
+def _agent_permission_config(agent: Agent) -> dict[str, str]:
+ """Translate this subagent's own access/network posture into OpenCode's
+ per-agent `permission:` frontmatter key (confirmed live: a `bash: deny`
+ entry here genuinely blocks the tool, independent of whatever the
+ parent session's own ruleset allows).
+
+ Mirrors `codex_agent_toml`'s `sandbox_mode(agent.permissions)` — the
+ same subagent-level `access` field already gates Codex's sandbox, so a
+ subagent left at the default `Permissions()` (access=READ) already gets
+ Codex's "read-only" treatment; without this, the identical subagent
+ compiled to OpenCode had no restriction at all, inheriting whatever the
+ parent session/process happened to allow. Approval is intentionally not
+ translated here, matching Codex's own subagent compiler, since there is
+ no live per-request approval signal at subagent granularity.
+ """
+
+ permissions = agent.permissions
+ denies: dict[str, str] = {}
+ if permissions.access not in (Access.WRITE, Access.FULL):
+ for tool in ("write", "edit", "apply_patch"):
+ denies[tool] = "deny"
+ if permissions.access is not Access.FULL:
+ denies["bash"] = "deny"
+ if not permissions.network:
+ for tool in ("webfetch", "websearch"):
+ denies[tool] = "deny"
+ return denies
+
+
+def yaml_string(value: str) -> str:
+ # A JSON string literal is also a valid YAML double-quoted scalar, so
+ # this avoids hand-rolling YAML quoting/escaping rules — same trick
+ # codex_agents.py's toml_string uses for TOML basic strings.
+ return json.dumps(value)
diff --git a/src/yoke/providers/opencode/db.py b/src/yoke/providers/opencode/db.py
new file mode 100644
index 0000000..3fbfcce
--- /dev/null
+++ b/src/yoke/providers/opencode/db.py
@@ -0,0 +1,33 @@
+"""Read-only access to OpenCode's own SQLite database.
+
+OpenCode's SSE stream was found unreliable for live progress narration in the
+prior spike this adapter is ported from (CodeAlmanac, 2026-07-09); polling
+OpenCode's own part/message tables is the proven alternative. Yoke doesn't
+own this schema, so every query tolerates a missing file, missing table, or
+corrupt database by returning an empty result instead of raising.
+"""
+
+from __future__ import annotations
+
+import sqlite3
+from pathlib import Path
+
+SQLiteRow = sqlite3.Row
+
+
+def query_readonly_or_empty(
+ path: Path,
+ sql: str,
+ params: tuple = (),
+) -> tuple[SQLiteRow, ...]:
+ try:
+ connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
+ connection.row_factory = sqlite3.Row
+ except sqlite3.Error:
+ return ()
+ try:
+ return tuple(connection.execute(sql, params).fetchall())
+ except sqlite3.Error:
+ return ()
+ finally:
+ connection.close()
diff --git a/src/yoke/providers/opencode/failures.py b/src/yoke/providers/opencode/failures.py
new file mode 100644
index 0000000..6382b42
--- /dev/null
+++ b/src/yoke/providers/opencode/failures.py
@@ -0,0 +1,44 @@
+"""Classify OpenCode run failures into normalized Yoke failures."""
+
+from __future__ import annotations
+
+from yoke.models import Failure
+from yoke.providers.opencode.progress import OPENCODE_STUCK_TOOL_CALL_ISSUE_URL
+
+
+def classify_opencode_failure(message: str, code: str | None = None) -> Failure:
+ if "not found on PATH" in message:
+ return Failure(
+ code="opencode.not_installed",
+ message="OpenCode was not found on PATH.",
+ fix=(
+ "Install OpenCode or update PATH so the `opencode` command "
+ "is available."
+ ),
+ raw=message,
+ )
+ if (
+ "did not report a listening port" in message
+ or "exited before it started listening" in message
+ ):
+ return Failure(
+ code="opencode.server_start_failed",
+ message=message,
+ fix="Run `opencode serve` directly to check for a startup error.",
+ raw=message,
+ )
+ if "tool call has been stuck" in message:
+ return Failure(
+ code="opencode.stuck_tool_call",
+ message=message,
+ fix=(
+ "This is a known upstream OpenCode reliability issue "
+ f"({OPENCODE_STUCK_TOOL_CALL_ISSUE_URL}), not specific to this "
+ "run. Retrying often succeeds; a different model may avoid the "
+ "tool call shape that triggers it."
+ ),
+ raw=message,
+ )
+ if "timed out" in message:
+ return Failure(code="opencode.timeout", message=message, raw=message)
+ return Failure(code=code or "opencode.request_failed", message=message, raw=message)
diff --git a/src/yoke/providers/opencode/fields.py b/src/yoke/providers/opencode/fields.py
new file mode 100644
index 0000000..9fd219b
--- /dev/null
+++ b/src/yoke/providers/opencode/fields.py
@@ -0,0 +1,32 @@
+"""Loose JSON field helpers for OpenCode HTTP/DB payloads."""
+
+from __future__ import annotations
+
+import json
+
+from pydantic import JsonValue
+
+from yoke.providers._fields import (
+ JsonObject,
+ as_record,
+ is_record,
+ number_field,
+ string_field,
+)
+
+__all__ = [
+ "JsonObject",
+ "as_record",
+ "is_record",
+ "number_field",
+ "string_field",
+ "stringify_json_value",
+]
+
+
+def stringify_json_value(value: JsonValue | None) -> str | None:
+ if value is None:
+ return None
+ if isinstance(value, str):
+ return value
+ return json.dumps(value, separators=(",", ":"), sort_keys=True)
diff --git a/src/yoke/providers/opencode/hooks.py b/src/yoke/providers/opencode/hooks.py
new file mode 100644
index 0000000..9ee0fd8
--- /dev/null
+++ b/src/yoke/providers/opencode/hooks.py
@@ -0,0 +1,195 @@
+"""Generated OpenCode plugin + local bridge for `tool.execute.before` hooks.
+
+Confirmed live in a 2026-07-12 spike: a generated `tool.execute.before`
+plugin hook is awaited (blocking) by OpenCode before a tool runs, can mutate
+`output.args` in place (the actually-executed tool call reflects the
+mutation — replacing the whole `output.args` object does *not* work, only
+per-key assignment does), and can block the call outright by throwing. The
+hook runs inside the `opencode serve` child process, not this one, so it
+needs a local HTTP round trip to reach a Python-side decision — this module
+is that bridge.
+
+Opt-in only: OpencodeServer only deploys this plugin and starts the bridge
+when a caller configures `ProviderOptions.opencode.request_handler`/
+`.policy` at session start (see `_maybe_deploy_hook_bridge` in
+opencode_server.py). A tool call this adapter doesn't have an opinion on
+defaults to allow-unchanged, not deny — unlike a permission request, hook
+interception is something a caller opts into per tool, not a mandatory
+approval gate.
+"""
+
+from __future__ import annotations
+
+import json
+import threading
+from collections.abc import Callable
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+from yoke.models import Event, EventKind, Request, RequestKind, Response, Tool
+from yoke.providers.opencode.fields import JsonObject, as_record, string_field
+from yoke.providers.opencode.parts import infer_opencode_tool_kind
+from yoke.providers.opencode.permissions import policy_response
+
+OPENCODE_HOOK_PLUGIN_SOURCE = """\
+export const YokeToolHook = async () => {
+ const bridgeUrl = process.env.YOKE_HOOK_BRIDGE_URL;
+ if (!bridgeUrl) {
+ return {};
+ }
+ return {
+ "tool.execute.before": async (input, output) => {
+ const res = await fetch(bridgeUrl + "/tool-hook", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ sessionID: input.sessionID,
+ callID: input.callID,
+ tool: input.tool,
+ args: output.args,
+ }),
+ // A hung bridge (e.g. a caller's request_handler blocking
+ // indefinitely on I/O) would otherwise block this tool call
+ // forever with no way for the caller to recover. A timed-out
+ // fetch throws, which — same as any other throw here — blocks
+ // the call rather than silently letting it through.
+ signal: AbortSignal.timeout(30000),
+ });
+ const decision = await res.json();
+ if (decision.args) {
+ for (const key of Object.keys(decision.args)) {
+ output.args[key] = decision.args[key];
+ }
+ }
+ if (decision.deny) {
+ throw new Error(decision.message || "Denied by Yoke.");
+ }
+ },
+ };
+};
+"""
+
+ResolveCallback = Callable[[str, JsonObject], Response]
+
+
+class OpencodeHookBridge:
+ """Local HTTP server the generated plugin calls into for each tool call.
+
+ Process-scoped, not session-scoped: the plugin is addressed via an env
+ var fixed at `opencode serve` spawn time, and a fork shares its parent's
+ process/env, so there is exactly one bridge per process regardless of
+ how many sessions (forks) run on it. `resolve` is given the tool call's
+ own `sessionID` to route to the right session's current policy/event
+ stream.
+ """
+
+ def __init__(self, resolve: ResolveCallback) -> None:
+ bridge = self
+ self._resolve = resolve
+
+ class Handler(BaseHTTPRequestHandler):
+ def do_POST(self) -> None: # noqa: N802 - stdlib handler name
+ length = int(self.headers.get("Content-Length", 0))
+ try:
+ payload = as_record(json.loads(self.rfile.read(length) or b"{}"))
+ except ValueError:
+ payload = {}
+ # A bare exception here (a bug in a caller's request_handler,
+ # or a Response.updated_input value json.dumps can't
+ # serialize) would otherwise reset the connection with no
+ # response — the plugin's `await res.json()` then throws
+ # inside tool.execute.before, which per its own semantics
+ # blocks the tool call. That's an indistinguishable silent
+ # deny with no REQUEST_RESOLVED event ever emitted. Turn it
+ # into an honest, visible one instead.
+ try:
+ session_id = string_field(payload, "sessionID") or ""
+ response = bridge._resolve(session_id, payload)
+ body = json.dumps(
+ {
+ "args": response.updated_input,
+ "deny": response.decision != "allow",
+ "message": response.message,
+ }
+ ).encode()
+ except Exception as error: # noqa: BLE001 - reported below
+ body = json.dumps(
+ {
+ "args": None,
+ "deny": True,
+ "message": f"Yoke hook bridge error: {error}",
+ }
+ ).encode()
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def log_message(self, format_string: str, *args: object) -> None:
+ pass
+
+ self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+ self._thread = threading.Thread(
+ target=self._server.serve_forever, daemon=True
+ )
+
+ @property
+ def base_url(self) -> str:
+ host, port = self._server.server_address
+ return f"http://{host}:{port}"
+
+ def start(self) -> None:
+ self._thread.start()
+
+ def stop(self) -> None:
+ self._server.shutdown()
+ self._server.server_close()
+
+
+def hook_event(payload: JsonObject) -> Event:
+ """Build the provider-neutral event for one intercepted tool call."""
+
+ tool_name = string_field(payload, "tool") or "tool"
+ args = payload.get("args")
+ command = string_field(args, "command") if isinstance(args, dict) else None
+ tool = Tool(
+ kind=infer_opencode_tool_kind(tool_name),
+ title=tool_name,
+ command=command,
+ )
+ message = f"OpenCode is about to run {tool_name}"
+ call_id = string_field(payload, "callID")
+ # Allow-unchanged by default: this bridge only exists because the
+ # caller opted in (see module docstring), so a tool this specific
+ # handler has no opinion on should pass through, not fail closed.
+ default = Response.allow()
+ return Event(
+ kind=EventKind.TOOL_REQUEST,
+ message=message,
+ tool_id=call_id,
+ tool_name=tool_name,
+ tool=tool,
+ request=Request(
+ kind=RequestKind.TOOL,
+ id=call_id,
+ method=tool_name,
+ message=message,
+ tool=tool,
+ input=args,
+ default=default,
+ raw=payload,
+ ),
+ response=default,
+ source_thread_id=string_field(payload, "sessionID"),
+ raw=payload,
+ )
+
+
+def resolve(
+ payload: JsonObject, request_handler: object | None
+) -> tuple[Event, Response]:
+ """Resolve one intercepted tool call, returning the event and decision."""
+
+ event = hook_event(payload)
+ response = policy_response(event, request_handler)
+ return event, response
diff --git a/src/yoke/providers/opencode/http.py b/src/yoke/providers/opencode/http.py
new file mode 100644
index 0000000..06d5895
--- /dev/null
+++ b/src/yoke/providers/opencode/http.py
@@ -0,0 +1,269 @@
+"""Synchronous OpenCode HTTP client.
+
+Endpoints per https://opencode.ai/docs/server/. One short-lived
+`httpx.Client` per call keeps this module free of connection-lifetime state;
+the caller owns the server process lifetime.
+"""
+
+from __future__ import annotations
+
+import httpx
+
+from yoke.providers.opencode.fields import JsonObject, as_record
+
+OPENCODE_ALLOW_ALL_PERMISSION: tuple[JsonObject, ...] = (
+ {"permission": "*", "pattern": "*", "action": "allow"},
+)
+OPENCODE_ASK_ALL_PERMISSION: tuple[JsonObject, ...] = (
+ {"permission": "*", "pattern": "*", "action": "ask"},
+)
+
+
+def get_providers(base_url: str, timeout_seconds: float) -> tuple[JsonObject, ...]:
+ response = httpx.get(f"{base_url}/config/providers", timeout=timeout_seconds)
+ response.raise_for_status()
+ payload = as_record(response.json())
+ providers = payload.get("providers")
+ if not isinstance(providers, list):
+ return ()
+ return tuple(as_record(item) for item in providers if isinstance(item, dict))
+
+
+def create_session(
+ base_url: str,
+ cwd_directory: str,
+ title: str,
+ timeout_seconds: float,
+ *,
+ permission: tuple[JsonObject, ...] = OPENCODE_ALLOW_ALL_PERMISSION,
+) -> JsonObject:
+ body: JsonObject = {"title": title}
+ if permission:
+ body["permission"] = list(permission)
+ response = httpx.post(
+ f"{base_url}/session",
+ params={"directory": cwd_directory},
+ json=body,
+ timeout=timeout_seconds,
+ )
+ response.raise_for_status()
+ return as_record(response.json())
+
+
+def list_sessions(
+ base_url: str,
+ timeout_seconds: float,
+ *,
+ directory: str | None = None,
+) -> tuple[JsonObject, ...]:
+ params = {"directory": directory} if directory is not None else None
+ response = httpx.get(f"{base_url}/session", params=params, timeout=timeout_seconds)
+ response.raise_for_status()
+ payload = response.json()
+ if not isinstance(payload, list):
+ return ()
+ return tuple(as_record(item) for item in payload if isinstance(item, dict))
+
+
+def list_messages(
+ base_url: str, session_id: str, timeout_seconds: float
+) -> tuple[JsonObject, ...]:
+ """List a session's stored messages via the documented history API.
+
+ GET /session/:id/message (operationId session.messages), confirmed live
+ (v1.17.15) to return every message in chronological order. No `limit` is
+ passed here — OpenCode's own `limit` keeps the most *recent* N messages,
+ which doesn't compose with offset-based pagination over the full
+ (oldest-first) order; callers slice the full result locally instead.
+ """
+
+ response = httpx.get(
+ f"{base_url}/session/{session_id}/message", timeout=timeout_seconds
+ )
+ response.raise_for_status()
+ payload = response.json()
+ if not isinstance(payload, list):
+ return ()
+ return tuple(as_record(item) for item in payload if isinstance(item, dict))
+
+
+def read_session(base_url: str, session_id: str, timeout_seconds: float) -> JsonObject:
+ response = httpx.get(f"{base_url}/session/{session_id}", timeout=timeout_seconds)
+ response.raise_for_status()
+ return as_record(response.json())
+
+
+def rename_session(
+ base_url: str,
+ session_id: str,
+ title: str,
+ timeout_seconds: float,
+) -> JsonObject:
+ response = httpx.patch(
+ f"{base_url}/session/{session_id}",
+ json={"title": title},
+ timeout=timeout_seconds,
+ )
+ response.raise_for_status()
+ return as_record(response.json())
+
+
+def update_session_permission(
+ base_url: str,
+ session_id: str,
+ timeout_seconds: float,
+ *,
+ permission: tuple[JsonObject, ...],
+) -> JsonObject:
+ """Set a session's permission ruleset via PATCH /session/:id.
+
+ Confirmed live (v1.17.15): `POST /session/:id/fork` does not inherit the
+ parent's ruleset — a forked session starts with none at all (default
+ allow) regardless of how restrictive the parent was. This PATCH endpoint
+ is the only documented way to (re)apply one after the fact.
+ """
+
+ response = httpx.patch(
+ f"{base_url}/session/{session_id}",
+ json={"permission": list(permission)},
+ timeout=timeout_seconds,
+ )
+ response.raise_for_status()
+ return as_record(response.json())
+
+
+def delete_session(base_url: str, session_id: str, timeout_seconds: float) -> None:
+ response = httpx.delete(f"{base_url}/session/{session_id}", timeout=timeout_seconds)
+ response.raise_for_status()
+
+
+def fork_session(
+ base_url: str,
+ session_id: str,
+ timeout_seconds: float,
+ *,
+ message_id: str | None = None,
+) -> JsonObject:
+ body: JsonObject = {"messageID": message_id} if message_id is not None else {}
+ response = httpx.post(
+ f"{base_url}/session/{session_id}/fork",
+ json=body,
+ timeout=timeout_seconds,
+ )
+ response.raise_for_status()
+ return as_record(response.json())
+
+
+def abort_session(base_url: str, session_id: str, timeout_seconds: float) -> None:
+ response = httpx.post(
+ f"{base_url}/session/{session_id}/abort", timeout=timeout_seconds
+ )
+ response.raise_for_status()
+
+
+def summarize_session(
+ base_url: str,
+ session_id: str,
+ provider_id: str,
+ model_id: str,
+ timeout_seconds: float,
+) -> None:
+ response = httpx.post(
+ f"{base_url}/session/{session_id}/summarize",
+ json={"providerID": provider_id, "modelID": model_id},
+ timeout=timeout_seconds,
+ )
+ response.raise_for_status()
+
+
+def post_message(
+ base_url: str,
+ session_id: str,
+ cwd_directory: str,
+ provider_id: str,
+ model_id: str,
+ prompt: str,
+ timeout_seconds: float,
+ *,
+ system: str | None = None,
+) -> JsonObject:
+ body: JsonObject = {
+ "model": {"providerID": provider_id, "modelID": model_id},
+ "parts": [{"type": "text", "text": prompt}],
+ }
+ if system is not None:
+ body["system"] = system
+ response = httpx.post(
+ f"{base_url}/session/{session_id}/message",
+ params={"directory": cwd_directory},
+ json=body,
+ timeout=timeout_seconds,
+ )
+ response.raise_for_status()
+ return as_record(response.json())
+
+
+def list_permissions(base_url: str, timeout_seconds: float) -> tuple[JsonObject, ...]:
+ """List pending permission requests across all sessions.
+
+ GET /permission (operationId permission.list) — confirmed live: a bash
+ permission set to "ask" appeared here immediately and was still listed
+ right up until it was answered via respond_permission(). This is the
+ real, non-deprecated discovery mechanism; /session/:id/permissions/
+ :permissionID (the endpoint this module used before) is marked
+ deprecated in OpenCode's own OpenAPI doc.
+ """
+
+ response = httpx.get(f"{base_url}/permission", timeout=timeout_seconds)
+ response.raise_for_status()
+ payload = response.json()
+ if not isinstance(payload, list):
+ return ()
+ return tuple(as_record(item) for item in payload if isinstance(item, dict))
+
+
+def respond_permission(
+ base_url: str,
+ permission_id: str,
+ reply: str,
+ timeout_seconds: float,
+ *,
+ message: str | None = None,
+) -> None:
+ """Answer a pending permission via POST /permission/:requestID/reply.
+
+ `reply` is one of "once", "always", "reject" (OpenCode's own enum).
+ """
+
+ body: JsonObject = {"reply": reply}
+ if message is not None:
+ body["message"] = message
+ response = httpx.post(
+ f"{base_url}/permission/{permission_id}/reply",
+ json=body,
+ timeout=timeout_seconds,
+ )
+ response.raise_for_status()
+
+
+def set_auth(
+ base_url: str,
+ provider_id: str,
+ api_key: str,
+ timeout_seconds: float,
+) -> None:
+ response = httpx.put(
+ f"{base_url}/auth/{provider_id}",
+ json={"type": "api", "key": api_key},
+ timeout=timeout_seconds,
+ )
+ response.raise_for_status()
+
+
+def list_agents(base_url: str, timeout_seconds: float) -> tuple[JsonObject, ...]:
+ response = httpx.get(f"{base_url}/agent", timeout=timeout_seconds)
+ response.raise_for_status()
+ payload = response.json()
+ if not isinstance(payload, list):
+ return ()
+ return tuple(as_record(item) for item in payload if isinstance(item, dict))
diff --git a/src/yoke/providers/opencode/parts.py b/src/yoke/providers/opencode/parts.py
new file mode 100644
index 0000000..3239596
--- /dev/null
+++ b/src/yoke/providers/opencode/parts.py
@@ -0,0 +1,238 @@
+"""Map OpenCode session 'part' JSON to normalized Yoke events."""
+
+from __future__ import annotations
+
+from yoke.models import (
+ AgentCall,
+ Event,
+ EventKind,
+ Surface,
+ Tool,
+ ToolKind,
+ ToolStatus,
+ Usage,
+)
+from yoke.providers.opencode.fields import (
+ JsonObject,
+ as_record,
+ number_field,
+ string_field,
+ stringify_json_value,
+)
+from yoke.providers.opencode.usage import parse_opencode_usage
+
+_TOOL_TITLES = {
+ ToolKind.READ: "Reading file",
+ ToolKind.WRITE: "Writing file",
+ ToolKind.EDIT: "Editing file",
+ ToolKind.SEARCH: "Searching",
+ ToolKind.SHELL: "Running command",
+ ToolKind.WEB: "Web request",
+ ToolKind.AGENT: "Agent tool",
+}
+
+
+def map_opencode_part(part: JsonObject, session_id: str) -> tuple[Event, ...]:
+ part_type = string_field(part, "type")
+ if part_type == "text":
+ return _text_events(part)
+ if part_type == "reasoning":
+ return _reasoning_events(part)
+ if part_type == "tool":
+ return _tool_events(part, session_id)
+ if part_type == "patch":
+ return _patch_events(part)
+ if part_type == "step-finish":
+ return _step_finish_events(part)
+ return ()
+
+
+def _text_events(part: JsonObject) -> tuple[Event, ...]:
+ text = string_field(part, "text")
+ if text is None:
+ return ()
+ return (
+ Event(
+ kind=EventKind.TEXT,
+ surface=Surface.OPENCODE_SERVER,
+ message=text,
+ raw=part,
+ ),
+ )
+
+
+def _reasoning_events(part: JsonObject) -> tuple[Event, ...]:
+ text = string_field(part, "text")
+ if text is None:
+ return ()
+ return (
+ Event(
+ kind=EventKind.TOOL_SUMMARY,
+ surface=Surface.OPENCODE_SERVER,
+ message=text,
+ raw=part,
+ ),
+ )
+
+
+def _tool_events(part: JsonObject, session_id: str) -> tuple[Event, ...]:
+ tool_name = string_field(part, "tool") or "tool"
+ call_id = string_field(part, "callID")
+ state = as_record(part.get("state"))
+ tool = opencode_tool(tool_name, state)
+ agent = _task_agent_call(part, state, session_id)
+ use_event = Event(
+ kind=EventKind.TOOL_USE,
+ surface=Surface.OPENCODE_SERVER,
+ message=tool.title or tool_name,
+ tool_id=call_id,
+ tool_name=tool_name,
+ tool_input=stringify_json_value(state.get("input")),
+ tool=tool,
+ agent=agent,
+ source_thread_id=session_id,
+ raw=part,
+ )
+ result_event = Event(
+ kind=EventKind.TOOL_RESULT,
+ surface=Surface.OPENCODE_SERVER,
+ message=tool.title or f"{tool_name} completed",
+ tool_id=call_id,
+ tool_name=tool_name,
+ tool=tool,
+ tool_result=state.get("output"),
+ tool_is_error=tool.status == ToolStatus.FAILED,
+ agent=agent,
+ source_thread_id=session_id,
+ raw=part,
+ )
+ return (use_event, result_event)
+
+
+def _task_agent_call(
+ part: JsonObject,
+ state: JsonObject,
+ session_id: str,
+) -> AgentCall | None:
+ if string_field(part, "tool") != "task":
+ return None
+ metadata = as_record(state.get("metadata"))
+ child_session_id = string_field(metadata, "sessionId")
+ if child_session_id is None:
+ return None
+ input_record = as_record(state.get("input"))
+ prompt = string_field(input_record, "prompt") or string_field(
+ input_record, "description"
+ )
+ status = string_field(state, "status")
+ action = "completed" if status in ("completed", "error") else "spawned"
+ return AgentCall(
+ action=action,
+ agent_type="task",
+ sender_thread_id=session_id,
+ new_thread_id=child_session_id,
+ prompt=prompt,
+ )
+
+
+def opencode_tool(tool_name: str, state: JsonObject) -> Tool:
+ metadata = as_record(state.get("metadata"))
+ input_record = as_record(state.get("input"))
+ kind = infer_opencode_tool_kind(tool_name)
+ return Tool(
+ kind=kind,
+ title=string_field(state, "title") or _TOOL_TITLES.get(kind, tool_name),
+ path=string_field(input_record, "filePath")
+ or string_field(input_record, "path"),
+ command=string_field(input_record, "command"),
+ status=opencode_tool_status(state),
+ exit_code=number_field(metadata, "exit"),
+ )
+
+
+def opencode_tool_status(state: JsonObject) -> ToolStatus:
+ status = string_field(state, "status")
+ if status == "error":
+ return ToolStatus.FAILED
+ return ToolStatus.COMPLETED
+
+
+def infer_opencode_tool_kind(tool: str) -> ToolKind:
+ normalized = tool.lower()
+ if "read" in normalized:
+ return ToolKind.READ
+ if "write" in normalized:
+ return ToolKind.WRITE
+ if "edit" in normalized or "patch" in normalized:
+ return ToolKind.EDIT
+ if any(word in normalized for word in ("grep", "glob", "search", "find", "ls")):
+ return ToolKind.SEARCH
+ if "bash" in normalized or "shell" in normalized:
+ return ToolKind.SHELL
+ if "web" in normalized or "fetch" in normalized:
+ return ToolKind.WEB
+ if "task" in normalized or "agent" in normalized:
+ return ToolKind.AGENT
+ return ToolKind.UNKNOWN
+
+
+def _patch_events(part: JsonObject) -> tuple[Event, ...]:
+ files = part.get("files")
+ if not isinstance(files, list) or len(files) == 0:
+ return ()
+ names = ", ".join(str(item) for item in files)
+ return (
+ Event(
+ kind=EventKind.TOOL_SUMMARY,
+ surface=Surface.OPENCODE_SERVER,
+ message=f"files changed: {names}",
+ raw=part,
+ ),
+ )
+
+
+def _step_finish_events(part: JsonObject) -> tuple[Event, ...]:
+ usage = parse_opencode_usage(part.get("tokens"))
+ if usage is None:
+ return ()
+ return (
+ Event(
+ kind=EventKind.CONTEXT_USAGE,
+ surface=Surface.OPENCODE_SERVER,
+ message=usage_message(usage),
+ usage=usage,
+ raw=part,
+ ),
+ )
+
+
+def usage_message(usage: Usage) -> str:
+ if usage.total_tokens is not None:
+ return f"usage: {usage.total_tokens} tokens"
+ return "usage updated"
+
+
+def final_text_from_parts(parts: list[JsonObject]) -> str | None:
+ for part in reversed(parts):
+ if string_field(part, "type") == "text":
+ text = string_field(part, "text")
+ if text is not None:
+ return text
+ return None
+
+
+def is_task_spawn(part: JsonObject) -> tuple[str, str] | None:
+ """(child_session_id, prompt) if this part spawns a sub-agent session."""
+
+ if string_field(part, "type") != "tool" or string_field(part, "tool") != "task":
+ return None
+ state = as_record(part.get("state"))
+ metadata = as_record(state.get("metadata"))
+ child_session_id = string_field(metadata, "sessionId")
+ if child_session_id is None:
+ return None
+ input_record = as_record(state.get("input"))
+ prompt = string_field(input_record, "prompt") or string_field(
+ input_record, "description"
+ )
+ return child_session_id, (prompt or "")
diff --git a/src/yoke/providers/opencode/permissions.py b/src/yoke/providers/opencode/permissions.py
new file mode 100644
index 0000000..5d06108
--- /dev/null
+++ b/src/yoke/providers/opencode/permissions.py
@@ -0,0 +1,156 @@
+"""Poll OpenCode's pending-permission list and resolve it through a policy.
+
+`GET /permission` is a real, non-deprecated, polling-discoverable endpoint —
+confirmed live in a 2026-07-12 spike: a session created with a bash
+permission set to "ask" produced a pending entry here immediately, and the
+in-flight `POST /session/:id/message` call (blocked waiting on that specific
+permission) completed as soon as it was answered via
+`POST /permission/:id/reply`. That disproves this adapter's earlier
+assumption that a pending permission is "only learnable via SSE" — it fits
+the same poll-not-SSE architecture as `OpencodeProgressWatchdog`
+(progress.py), not a generated plugin or a local HTTP callback listener.
+"""
+
+from __future__ import annotations
+
+import threading
+from collections.abc import Callable
+
+from yoke.models import (
+ Event,
+ EventKind,
+ Request,
+ RequestKind,
+ Response,
+ Tool,
+ ToolStatus,
+)
+from yoke.providers.opencode import http
+from yoke.providers.opencode.fields import JsonObject, string_field
+from yoke.providers.opencode.parts import infer_opencode_tool_kind
+
+Callback = Callable[[Event], None]
+
+OPENCODE_PERMISSION_POLL_INTERVAL_SECONDS = 1.0
+OPENCODE_PERMISSION_REQUEST_TIMEOUT_SECONDS = 10.0
+
+
+class OpencodePermissionWatchdog:
+ """Poll pending permissions for one session and resolve them via a handler."""
+
+ def __init__(
+ self,
+ base_url: str,
+ session_id: str,
+ on_event: Callback,
+ request_handler: object | None,
+ poll_interval_seconds: float = OPENCODE_PERMISSION_POLL_INTERVAL_SECONDS,
+ timeout_seconds: float = OPENCODE_PERMISSION_REQUEST_TIMEOUT_SECONDS,
+ seen_permission_ids: set[str] | None = None,
+ ) -> None:
+ self.base_url = base_url
+ self.session_id = session_id
+ self.on_event = on_event
+ self.request_handler = request_handler
+ self.poll_interval_seconds = poll_interval_seconds
+ self.timeout_seconds = timeout_seconds
+ self._seen: set[str] = (
+ seen_permission_ids if seen_permission_ids is not None else set()
+ )
+
+ def run(self, stop_event: threading.Event) -> None:
+ while not stop_event.is_set():
+ self._poll_once()
+ stop_event.wait(self.poll_interval_seconds)
+ # One final pass: a permission asked right before the sender thread
+ # returned could otherwise go unanswered and unreported.
+ self._poll_once()
+
+ def _poll_once(self) -> None:
+ try:
+ pending = http.list_permissions(self.base_url, self.timeout_seconds)
+ except Exception: # noqa: BLE001 - transient poll errors retried next tick
+ return
+ for record in pending:
+ permission_id = string_field(record, "id")
+ if permission_id is None or permission_id in self._seen:
+ continue
+ if string_field(record, "sessionID") != self.session_id:
+ continue
+ self._resolve(record, permission_id)
+
+ def _resolve(self, record: JsonObject, permission_id: str) -> None:
+ event = permission_event(record, permission_id)
+ response = policy_response(event, self.request_handler)
+ reply = "once" if response.decision == "allow" else "reject"
+ try:
+ http.respond_permission(
+ self.base_url,
+ permission_id,
+ reply,
+ self.timeout_seconds,
+ message=response.message,
+ )
+ except Exception: # noqa: BLE001 - reply failures retried next poll tick
+ return
+ # Only mark seen once the reply actually lands — a transient reply
+ # failure used to be swallowed after the ID was recorded, so the
+ # watchdog never retried it and the in-flight message stayed blocked
+ # until its own outer timeout.
+ self._seen.add(permission_id)
+ self.on_event(
+ event.model_copy(
+ update={"kind": EventKind.REQUEST_RESOLVED, "response": response}
+ )
+ )
+
+
+def permission_event(record: JsonObject, permission_id: str) -> Event:
+ """Build the provider-neutral event for one pending OpenCode permission."""
+
+ permission_kind = string_field(record, "permission") or "permission"
+ metadata = record.get("metadata")
+ command = (
+ string_field(metadata, "command") if isinstance(metadata, dict) else None
+ )
+ tool = Tool(
+ kind=infer_opencode_tool_kind(permission_kind),
+ title=permission_kind,
+ command=command,
+ status=ToolStatus.STARTED,
+ )
+ message = f"OpenCode requested {permission_kind} permission"
+ # Fail closed, matching RequestPolicy's own default (policies.py): a
+ # permission this adapter can't resolve is denied, not silently left
+ # pending until the caller's timeout.
+ default = Response.deny(f"Denied by Yoke: no permission policy for {message!r}.")
+ return Event(
+ kind=EventKind.APPROVAL_REQUEST,
+ message=message,
+ tool_id=permission_id,
+ tool_name=permission_kind,
+ tool=tool,
+ request=Request(
+ kind=RequestKind.PERMISSION,
+ id=permission_id,
+ method=permission_kind,
+ message=message,
+ tool=tool,
+ input=record,
+ default=default,
+ raw=record,
+ ),
+ response=default,
+ source_thread_id=string_field(record, "sessionID"),
+ raw=record,
+ )
+
+
+def policy_response(event: Event, request_handler: object | None) -> Response:
+ """Return the handler's decision, or the event's default if none applies."""
+
+ default = event.response or Response.deny()
+ if request_handler is None or not callable(request_handler):
+ return default
+ response = request_handler(event, default)
+ return response if isinstance(response, Response) else default
diff --git a/src/yoke/providers/opencode/process.py b/src/yoke/providers/opencode/process.py
new file mode 100644
index 0000000..3c3f172
--- /dev/null
+++ b/src/yoke/providers/opencode/process.py
@@ -0,0 +1,125 @@
+"""Synchronous `opencode serve` process spawn.
+
+Mirrors codex_app/process.py's precedent: a synchronous, thread-backed
+process wrapper that async adapter methods bridge to via `asyncio.to_thread`,
+rather than a native asyncio subprocess rewrite. This lets the polling-based
+progress design (progress.py) share the same threading model.
+"""
+
+from __future__ import annotations
+
+import os
+import queue
+import re
+import shutil
+import subprocess
+import threading
+import time
+from pathlib import Path
+
+from yoke.errors import YokeError
+
+OPENCODE_SERVER_STARTUP_TIMEOUT_SECONDS = 10.0
+OPENCODE_SERVER_TERMINATE_TIMEOUT_SECONDS = 5.0
+_LISTENING_PATTERN = re.compile(r"listening on http://127\.0\.0\.1:(\d+)")
+
+
+class OpencodeServerStartupError(YokeError):
+ pass
+
+
+class OpencodeServerProcess:
+ def __init__(self, process: subprocess.Popen[str], base_url: str) -> None:
+ self.process = process
+ self.base_url = base_url
+
+ def __enter__(self) -> OpencodeServerProcess:
+ return self
+
+ def __exit__(self, *exc_info: object) -> None:
+ self.terminate()
+
+ def terminate(self) -> None:
+ if self.process.poll() is not None:
+ return
+ self.process.terminate()
+ try:
+ self.process.wait(timeout=OPENCODE_SERVER_TERMINATE_TIMEOUT_SECONDS)
+ except subprocess.TimeoutExpired:
+ self.process.kill()
+ self.process.wait(timeout=OPENCODE_SERVER_TERMINATE_TIMEOUT_SECONDS)
+
+
+def start_opencode_server(
+ command: str,
+ cwd: Path,
+ startup_timeout_seconds: float = OPENCODE_SERVER_STARTUP_TIMEOUT_SECONDS,
+ env: dict[str, str] | None = None,
+) -> OpencodeServerProcess:
+ # Windows: npm-installed opencode ships as a .cmd/.ps1 shim, and
+ # subprocess.Popen(shell=False) can't launch a bare command name through
+ # CreateProcess the way a shell would.
+ resolved = shutil.which(command) or command
+ # subprocess.Popen(env=...) replaces the environment wholesale, not
+ # merges it — passing a partial dict (e.g. just OPENCODE_CONFIG_DIR)
+ # would silently strip HOME/PATH and degrade opencode to whatever
+ # providers/config it can find without them. Merge onto a copy of the
+ # real environment instead, matching codex_app/process.py's precedent.
+ process_env = dict(os.environ)
+ if env is not None:
+ process_env.update(env)
+ process = subprocess.Popen(
+ (resolved, "serve", "--port", "0"),
+ cwd=cwd,
+ env=process_env,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ )
+ try:
+ base_url = _wait_for_listening(process, startup_timeout_seconds)
+ except Exception:
+ process.terminate()
+ try:
+ process.wait(timeout=OPENCODE_SERVER_TERMINATE_TIMEOUT_SECONDS)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ raise
+ return OpencodeServerProcess(process=process, base_url=base_url)
+
+
+def _wait_for_listening(process: subprocess.Popen[str], timeout_seconds: float) -> str:
+ assert process.stdout is not None
+ lines: queue.Queue[str | None] = queue.Queue()
+
+ def _reader() -> None:
+ assert process.stdout is not None
+ for line in process.stdout:
+ lines.put(line)
+ lines.put(None)
+
+ # A background thread draining stdout, not a blocking readline() loop:
+ # readline() blocks past any wall-clock deadline check between calls, and
+ # select() on pipes isn't available on Windows.
+ threading.Thread(target=_reader, daemon=True).start()
+
+ deadline = time.monotonic() + timeout_seconds
+ while True:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise OpencodeServerStartupError(
+ "opencode serve did not report a listening port in time"
+ )
+ try:
+ line = lines.get(timeout=remaining)
+ except queue.Empty as error:
+ raise OpencodeServerStartupError(
+ "opencode serve did not report a listening port in time"
+ ) from error
+ if line is None:
+ raise OpencodeServerStartupError(
+ "opencode serve exited before it started listening"
+ )
+ match = _LISTENING_PATTERN.search(line)
+ if match is not None:
+ return f"http://127.0.0.1:{match.group(1)}"
diff --git a/src/yoke/providers/opencode/progress.py b/src/yoke/providers/opencode/progress.py
new file mode 100644
index 0000000..2caab01
--- /dev/null
+++ b/src/yoke/providers/opencode/progress.py
@@ -0,0 +1,182 @@
+"""Poll OpenCode's own SQLite db while a run is in flight.
+
+Emits normalized Events for new parts across the root session and any
+sub-agent sessions discovered along the way, and detects a tool call stuck
+past a threshold. Runs on a background thread (see opencode_server.py, which
+bridges this into the async ProviderAdapter surface via asyncio.to_thread)
+because OpenCode's own SSE stream was found unreliable for this purpose in
+the prior spike this module is ported from.
+
+"Live" only covers terminal parts: a tool call still status: "running"
+produces no TOOL_USE narration yet — it only feeds the stuck-call check.
+"""
+
+from __future__ import annotations
+
+import json
+import threading
+import time
+from collections.abc import Callable
+from dataclasses import dataclass
+from pathlib import Path
+
+from yoke.errors import YokeError
+from yoke.models import Event
+from yoke.providers.opencode.db import query_readonly_or_empty
+from yoke.providers.opencode.fields import (
+ JsonObject,
+ as_record,
+ number_field,
+ string_field,
+ stringify_json_value,
+)
+from yoke.providers.opencode.parts import is_task_spawn, map_opencode_part
+
+Callback = Callable[[Event], None]
+
+# Joins message to filter to assistant-authored parts only — part rows alone
+# don't carry role, and a session's own part table includes the user's/
+# prompt's echoed-back input parts too.
+_PARTS_QUERY = """
+SELECT part.id AS id, part.data AS part_data, message.data AS message_data
+FROM part
+JOIN message ON message.id = part.message_id
+WHERE part.session_id = ?
+ORDER BY part.time_created
+"""
+
+OPENCODE_POLL_INTERVAL_SECONDS = 2.0
+OPENCODE_STUCK_TOOL_CALL_SECONDS = 240.0
+# OpenCode's tool-execution layer has no internal timeout, so a glob/read/bash
+# call can hang forever regardless of model — a confirmed upstream
+# reliability gap, not fixable from this adapter, only detectable.
+OPENCODE_STUCK_TOOL_CALL_ISSUE_URL = "github.com/anomalyco/opencode/issues/33541"
+
+
+@dataclass
+class OpencodeStuckToolCall:
+ tool_name: str
+ session_id: str
+ elapsed_seconds: float
+ tool_input: str | None = None
+
+
+class OpencodeStuckToolCallError(YokeError):
+ def __init__(self, info: OpencodeStuckToolCall):
+ self.info = info
+ super().__init__(
+ f'OpenCode\'s "{info.tool_name}" tool call has been stuck for '
+ f"{int(info.elapsed_seconds)}s+ with no response (session "
+ f"{info.session_id}) — this is a known upstream OpenCode "
+ f"reliability issue ({OPENCODE_STUCK_TOOL_CALL_ISSUE_URL}), not "
+ "specific to this run."
+ )
+
+
+class OpencodeProgressWatchdog:
+ def __init__(
+ self,
+ db_path: Path,
+ root_session_id: str,
+ on_event: Callback,
+ poll_interval_seconds: float = OPENCODE_POLL_INTERVAL_SECONDS,
+ stuck_after_seconds: float = OPENCODE_STUCK_TOOL_CALL_SECONDS,
+ known_sessions: set[str] | None = None,
+ seen_part_ids: set[str] | None = None,
+ ) -> None:
+ self.db_path = db_path
+ self.on_event = on_event
+ self.poll_interval_seconds = poll_interval_seconds
+ self.stuck_after_seconds = stuck_after_seconds
+
+ # Callers that span multiple turns on the same session (see
+ # opencode_server.py's per-session _seen_part_ids/_known_sessions)
+ # pass their own sets here so a later turn doesn't re-emit an
+ # earlier turn's already-seen parts as if they were new.
+ self._known_sessions: set[str] = (
+ known_sessions if known_sessions is not None else set()
+ )
+ self._known_sessions.add(root_session_id)
+ self._seen_part_ids: set[str] = (
+ seen_part_ids if seen_part_ids is not None else set()
+ )
+ self.stuck_reason: OpencodeStuckToolCall | None = None
+
+ def run(self, stop_event: threading.Event) -> None:
+ while not stop_event.is_set():
+ self._poll_once()
+ if self.stuck_reason is not None:
+ return
+ stop_event.wait(self.poll_interval_seconds)
+ # One final pass: the DB is fully written by the time the caller sets
+ # stop_event (only done after the blocking send returns), so this
+ # picks up anything the last timed poll cycle missed.
+ self._poll_once()
+
+ def _poll_once(self) -> None:
+ for session_id in tuple(self._known_sessions):
+ self._poll_session(session_id)
+ if self.stuck_reason is not None:
+ return
+
+ def _poll_session(self, session_id: str) -> None:
+ rows = query_readonly_or_empty(self.db_path, _PARTS_QUERY, (session_id,))
+ now_ms = int(time.time() * 1000)
+ for row in rows:
+ part_id = row["id"]
+ if part_id in self._seen_part_ids:
+ continue
+ part = _parse_part(row["part_data"])
+ if part is None:
+ continue
+ message = _parse_part(row["message_data"])
+ if message is not None and string_field(message, "role") != "assistant":
+ self._seen_part_ids.add(part_id)
+ continue
+
+ spawn = is_task_spawn(part)
+ if spawn is not None:
+ child_session_id, _ = spawn
+ self._known_sessions.add(child_session_id)
+
+ if string_field(part, "type") == "tool":
+ state = as_record(part.get("state"))
+ if string_field(state, "status") == "running":
+ self._check_stuck(session_id, part, state, now_ms)
+ if self.stuck_reason is not None:
+ return
+ continue # not terminal yet — re-check next poll
+
+ self._seen_part_ids.add(part_id)
+ for event in map_opencode_part(part, session_id):
+ self.on_event(event)
+
+ def _check_stuck(
+ self,
+ session_id: str,
+ part: JsonObject,
+ state: JsonObject,
+ now_ms: int,
+ ) -> None:
+ time_info = as_record(state.get("time"))
+ start_ms = number_field(time_info, "start")
+ if start_ms is None:
+ return
+ elapsed_seconds = (now_ms - start_ms) / 1000
+ if elapsed_seconds >= self.stuck_after_seconds:
+ self.stuck_reason = OpencodeStuckToolCall(
+ tool_name=string_field(part, "tool") or "tool",
+ session_id=session_id,
+ elapsed_seconds=elapsed_seconds,
+ tool_input=stringify_json_value(state.get("input")),
+ )
+
+
+def _parse_part(value: object) -> JsonObject | None:
+ if not isinstance(value, str):
+ return None
+ try:
+ parsed = json.loads(value)
+ except ValueError:
+ return None
+ return parsed if isinstance(parsed, dict) else None
diff --git a/src/yoke/providers/opencode/usage.py b/src/yoke/providers/opencode/usage.py
new file mode 100644
index 0000000..db5f369
--- /dev/null
+++ b/src/yoke/providers/opencode/usage.py
@@ -0,0 +1,22 @@
+"""Parse OpenCode's token usage payload into normalized Yoke usage."""
+
+from __future__ import annotations
+
+from pydantic import JsonValue
+
+from yoke.models import Usage
+from yoke.providers.opencode.fields import as_record, number_field
+
+
+def parse_opencode_usage(value: JsonValue | None) -> Usage | None:
+ obj = as_record(value)
+ if len(obj) == 0:
+ return None
+ cache = as_record(obj.get("cache"))
+ return Usage(
+ input_tokens=number_field(obj, "input"),
+ cached_input_tokens=number_field(cache, "read"),
+ output_tokens=number_field(obj, "output"),
+ reasoning_output_tokens=number_field(obj, "reasoning"),
+ total_tokens=number_field(obj, "total"),
+ )
diff --git a/src/yoke/providers/opencode_server.py b/src/yoke/providers/opencode_server.py
new file mode 100644
index 0000000..a04440d
--- /dev/null
+++ b/src/yoke/providers/opencode_server.py
@@ -0,0 +1,1077 @@
+"""OpenCode provider adapter.
+
+OpenCode has no Python SDK. This surface spawns `opencode serve --port 0` as
+a child process and drives it over HTTP. Following the precedent set by
+`CodexAppServer` (providers/codex_app/process.py), the process/HTTP/DB-
+polling mechanics stay synchronous and thread-backed, and this adapter's
+async `ProviderAdapter` methods bridge to them via `asyncio.to_thread`.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import threading
+from collections.abc import AsyncIterator, Callable
+from dataclasses import dataclass, field
+from pathlib import Path
+
+from yoke.errors import UnsupportedFeature, YokeError
+from yoke.models import (
+ Access,
+ Approval,
+ Event,
+ EventKind,
+ Goal,
+ GoalRun,
+ Harness,
+ Login,
+ Model,
+ Permissions,
+ Provider,
+ Readiness,
+ Response,
+ Run,
+ RunStatus,
+ Session,
+ SessionHistory,
+ SessionList,
+ SessionMessage,
+ SessionSummary,
+ Surface,
+ Turn,
+ Workflow,
+ WorkflowRun,
+)
+from yoke.options import (
+ ForkOptions,
+ GoalLoopOptions,
+ OpencodeOptions,
+ RunOptions,
+ SessionOptions,
+ WorkflowOptions,
+ opencode_request_handler,
+)
+from yoke.providers.opencode import http
+from yoke.providers.opencode.failures import classify_opencode_failure
+from yoke.providers.opencode.fields import JsonObject, as_record, string_field
+from yoke.providers.opencode.hooks import (
+ OPENCODE_HOOK_PLUGIN_SOURCE,
+ OpencodeHookBridge,
+)
+from yoke.providers.opencode.hooks import resolve as resolve_hook_request
+from yoke.providers.opencode.parts import final_text_from_parts
+from yoke.providers.opencode.permissions import OpencodePermissionWatchdog
+from yoke.providers.opencode.process import (
+ OpencodeServerProcess,
+ OpencodeServerStartupError,
+ start_opencode_server,
+)
+from yoke.providers.opencode.progress import (
+ OPENCODE_POLL_INTERVAL_SECONDS,
+ OPENCODE_STUCK_TOOL_CALL_SECONDS,
+ OpencodeProgressWatchdog,
+ OpencodeStuckToolCallError,
+)
+from yoke.providers.opencode.usage import parse_opencode_usage
+from yoke.providers.runtime_deployment import RuntimeDeployment, deploy_runtime
+from yoke.surfaces import capabilities_for
+from yoke.workflows import native_workflow_unsupported
+
+OPENCODE_COMMAND = "opencode"
+OPENCODE_MODEL_SEPARATOR = "/"
+OPENCODE_CHECK_STARTUP_TIMEOUT_SECONDS = 5.0
+OPENCODE_CHECK_REQUEST_TIMEOUT_SECONDS = 5.0
+OPENCODE_RUN_STARTUP_TIMEOUT_SECONDS = 10.0
+OPENCODE_RUN_REQUEST_TIMEOUT_SECONDS = 900.0
+OPENCODE_NOT_INSTALLED_MESSAGE = "opencode not found on PATH"
+OPENCODE_SERVER_REPAIR = "run `opencode serve` directly to check for a startup error"
+OPENCODE_PROVIDER_REPAIR = (
+ "sign in with `opencode auth login` or configure a provider API key"
+)
+# How long the main thread waits between checks of the watchdog's
+# stuck_reason while the sender thread is still alive.
+OPENCODE_STUCK_CHECK_INTERVAL_SECONDS = 1.0
+
+
+def split_opencode_model(model: str) -> tuple[str, str]:
+ provider_id, separator, model_id = model.partition(OPENCODE_MODEL_SEPARATOR)
+ if separator == "" or provider_id == "" or model_id == "":
+ raise ValueError(f'opencode model must be "provider/model", got: {model!r}')
+ return provider_id, model_id
+
+
+@dataclass
+class _OpencodeSession:
+ process: OpencodeServerProcess
+ session_id: str
+ cwd: Path
+ environment: dict[str, str] | None
+ db_path: Path
+ # Persist across turns so a later send() doesn't re-poll and re-emit an
+ # earlier turn's already-seen parts through on_event.
+ known_sessions: set[str] = field(default_factory=set)
+ seen_part_ids: set[str] = field(default_factory=set)
+ # Persisted the same way as seen_part_ids, so a permission answered on an
+ # earlier turn isn't rediscovered and re-resolved on a later one.
+ seen_permission_ids: set[str] = field(default_factory=set)
+ # POST /session/:id/message's `system` field is per-message, not
+ # session-persistent (confirmed live) — sent on every turn from this,
+ # not tracked as a one-time flag.
+ instructions: str | None = None
+ # Session-level provider.opencode options (mirrors CodexAppServer's
+ # thread.provider_options): a later turn's RunOptions.provider.opencode
+ # overrides this, but falls back here when a turn doesn't set one.
+ provider_options: OpencodeOptions | dict[str, object] | None = None
+ # Set for the duration of one _send() call so a live tool.execute.before
+ # hook (OpencodeHookBridge — a long-lived, process-scoped server that
+ # outlives any one turn) can still emit its resolved event into whatever
+ # turn is currently in flight, and resolve against that turn's own
+ # RunOptions.provider.opencode override rather than the fixed
+ # session-start handler. Both None between turns and while idle.
+ current_emit: Callable[[Event], None] | None = None
+ current_request_handler: object | None = None
+
+
+class OpencodeServer:
+ """Adapter for `opencode serve --port 0`."""
+
+ provider: Provider = "opencode"
+ surface = "opencode_server"
+ capabilities = capabilities_for(provider, surface)
+
+ def __init__(
+ self,
+ command: str = OPENCODE_COMMAND,
+ *,
+ db_path: Path | None = None,
+ poll_interval_seconds: float = OPENCODE_POLL_INTERVAL_SECONDS,
+ stuck_after_seconds: float = OPENCODE_STUCK_TOOL_CALL_SECONDS,
+ ) -> None:
+ self.command = command
+ self._db_path_override = db_path
+ self.poll_interval_seconds = poll_interval_seconds
+ self.stuck_after_seconds = stuck_after_seconds
+ self._sessions: dict[str, _OpencodeSession] = {}
+ # A forked session shares its parent's server process (fork() opens
+ # a new session id on the same running `opencode serve` instance,
+ # not a new process), so termination is reference-counted rather
+ # than tied to any one session — mirrors CodexAppServer's
+ # _retain_process/_release_process.
+ self._process_refs: dict[int, int] = {}
+ # Keyed by id(process), not by session: a fork shares its parent's
+ # runtime deployment (skills dir, OPENCODE_CONFIG_DIR), so cleanup
+ # must wait for the last session referencing that process to close,
+ # not whichever session happens to close first. Mirrors
+ # CodexAppServer's own _deployments keyed by id(process).
+ self._deployments: dict[int, RuntimeDeployment] = {}
+ # Same id(process) keying as _deployments: one bridge per process,
+ # shared across forks, torn down alongside the deployment.
+ self._hook_bridges: dict[int, OpencodeHookBridge] = {}
+
+ async def check(self, harness: Harness) -> Readiness:
+ return await asyncio.to_thread(self._check, harness.cwd, harness.environment)
+
+ async def run(self, harness: Harness, prompt: str, options: RunOptions) -> Run:
+ # Regression: provider (needed to deploy the tool-hook bridge —
+ # OpencodeHookBridge only ever reads session-creation-time options,
+ # see _maybe_deploy_hook_bridge) was dropped here, matching neither
+ # CodexAppServer.run() nor this adapter's own per-turn permission
+ # resolution, which does see RunOptions.provider via
+ # opencode_options_for_run() in _send().
+ session = await self.start(
+ harness,
+ SessionOptions(
+ model=options.model,
+ goal=options.goal,
+ inherit_goal=options.inherit_goal,
+ effort=options.effort,
+ permissions=options.permissions,
+ provider=options.provider,
+ ),
+ )
+ try:
+ return await self.send(session, Turn(prompt=prompt), options)
+ finally:
+ await self.close(session)
+
+ async def models(self, harness: Harness) -> tuple[Model, ...]:
+ return await asyncio.to_thread(self._models, harness.cwd, harness.environment)
+
+ async def workflow(
+ self,
+ harness: Harness,
+ workflow: Workflow,
+ prompt: str,
+ options: WorkflowOptions,
+ ) -> WorkflowRun:
+ raise native_workflow_unsupported(
+ harness,
+ workflow,
+ options,
+ reason="OpenCode has no documented native workflow DSL.",
+ )
+
+ async def goal_loop(self, harness: Harness, options: GoalLoopOptions) -> GoalRun:
+ raise UnsupportedFeature("OpenCode has no goal-loop concept.")
+
+ async def get_goal(self, session: Session) -> Goal | None:
+ raise UnsupportedFeature("OpenCode has no goal concept.")
+
+ async def set_goal(self, session: Session, goal: Goal) -> Session:
+ raise UnsupportedFeature("OpenCode has no goal concept.")
+
+ async def clear_goal(self, session: Session) -> Session:
+ raise UnsupportedFeature("OpenCode has no goal concept.")
+
+ async def login(
+ self,
+ harness: Harness,
+ method: str,
+ *,
+ api_key: str | None = None,
+ ) -> Login:
+ if method != "api_key" or api_key is None:
+ raise UnsupportedFeature(
+ "OpenCode adapter only wires api_key login (PUT /auth/:id); "
+ "OAuth authorize/callback exists in the API but is not wired "
+ "in this adapter yet."
+ )
+ provider_id = (
+ harness.agent.model.split(OPENCODE_MODEL_SEPARATOR, 1)[0]
+ if (harness.agent.model and OPENCODE_MODEL_SEPARATOR in harness.agent.model)
+ else None
+ )
+ if provider_id is None:
+ raise UnsupportedFeature(
+ "api_key login needs a provider id; set Harness.agent.model to "
+ '"provider/model" first.'
+ )
+ await asyncio.to_thread(
+ self._login, harness.cwd, harness.environment, provider_id, api_key
+ )
+ return Login(
+ provider=self.provider,
+ surface=self.surface,
+ method="api_key",
+ success=True,
+ )
+
+ async def list_sessions(
+ self,
+ harness: Harness,
+ *,
+ limit: int | None = None,
+ cursor: str | None = None,
+ cwd: str | Path | None = None,
+ include_worktrees: bool = True,
+ ) -> SessionList:
+ if cursor is not None:
+ raise UnsupportedFeature("OpenCode session listing is not cursor-paged.")
+ if not include_worktrees:
+ raise UnsupportedFeature(
+ "OpenCode session listing has no worktree-exclusion filter."
+ )
+ sessions = await asyncio.to_thread(
+ self._list_sessions, harness.cwd, harness.environment, limit, cwd
+ )
+ return SessionList(
+ provider=self.provider, surface=self.surface, sessions=sessions
+ )
+
+ async def read_session(
+ self,
+ harness: Harness,
+ session_id: str,
+ *,
+ include_messages: bool = True,
+ limit: int | None = None,
+ offset: int = 0,
+ ) -> SessionHistory:
+ return await asyncio.to_thread(
+ self._read_session,
+ harness.cwd,
+ harness.environment,
+ session_id,
+ include_messages,
+ limit,
+ offset,
+ )
+
+ async def rename(self, session: Session, title: str) -> SessionSummary:
+ internal = self._require_internal(session)
+ record = await asyncio.to_thread(
+ http.rename_session,
+ internal.process.base_url,
+ internal.session_id,
+ title,
+ OPENCODE_CHECK_REQUEST_TIMEOUT_SECONDS,
+ )
+ return _session_summary(record, self.provider, self.surface)
+
+ async def tag(self, session: Session, tag: str | None) -> SessionSummary:
+ raise UnsupportedFeature("OpenCode does not expose a session tag API.")
+
+ async def fork(self, session: Session, options: ForkOptions) -> Session:
+ internal = self._require_internal(session)
+ record = await asyncio.to_thread(
+ http.fork_session,
+ internal.process.base_url,
+ internal.session_id,
+ OPENCODE_CHECK_REQUEST_TIMEOUT_SECONDS,
+ message_id=options.last_turn_id,
+ )
+ forked_id = string_field(record, "id")
+ if forked_id is None:
+ raise YokeError("opencode fork did not return a session id")
+ # OpenCode's fork endpoint does not inherit the parent session's
+ # permission ruleset — confirmed live: a session created with
+ # bash denied produced a fork with no ruleset at all (default
+ # allow), so bash ran freely on the fork despite the returned
+ # Session.permissions still claiming the parent's restricted
+ # posture. Re-apply it explicitly; PATCH /session/:id is the only
+ # documented way to set a ruleset after creation.
+ if session.permissions is not None:
+ await asyncio.to_thread(
+ http.update_session_permission,
+ internal.process.base_url,
+ forked_id,
+ OPENCODE_CHECK_REQUEST_TIMEOUT_SECONDS,
+ permission=_session_permission_block(session.permissions),
+ )
+ forked = _OpencodeSession(
+ process=internal.process,
+ session_id=forked_id,
+ cwd=internal.cwd,
+ environment=internal.environment,
+ db_path=internal.db_path,
+ # `system` is per-message, not persisted server-side (see _send),
+ # so the fork must keep sending it on every turn just like the
+ # parent did — carrying it over here, not marking it as already
+ # sent.
+ instructions=internal.instructions,
+ # Same reasoning as instructions: a fork should keep answering
+ # permissions the way the parent session was configured to,
+ # not silently fall back to "no handler configured" default-deny.
+ provider_options=internal.provider_options,
+ )
+ self._retain_process(internal.process)
+ self._sessions[forked_id] = forked
+ return Session(
+ provider=self.provider,
+ surface=self.surface,
+ id=forked_id,
+ provider_session_id=forked_id,
+ agent=session.agent,
+ cwd=session.cwd,
+ permissions=session.permissions,
+ model=session.model,
+ )
+
+ async def interrupt(self, session: Session) -> None:
+ internal = self._require_internal(session)
+ await asyncio.to_thread(
+ http.abort_session,
+ internal.process.base_url,
+ internal.session_id,
+ OPENCODE_CHECK_REQUEST_TIMEOUT_SECONDS,
+ )
+
+ async def compact(self, session: Session) -> None:
+ internal = self._require_internal(session)
+ if session.model is None:
+ raise YokeError("opencode compact needs a session model to summarize with.")
+ provider_id, model_id = split_opencode_model(session.model)
+ await asyncio.to_thread(
+ http.summarize_session,
+ internal.process.base_url,
+ internal.session_id,
+ provider_id,
+ model_id,
+ OPENCODE_RUN_REQUEST_TIMEOUT_SECONDS,
+ )
+
+ async def start(self, harness: Harness, options: SessionOptions) -> Session:
+ deployment = deploy_runtime(
+ harness.agent, Provider.OPENCODE, harness.runtime_root
+ )
+ try:
+ internal = await asyncio.to_thread(
+ self._start_session, harness, options, deployment
+ )
+ except Exception:
+ deployment.cleanup()
+ raise
+ self._retain_process(internal.process)
+ self._deployments[id(internal.process)] = deployment
+ self._sessions[internal.session_id] = internal
+ return Session(
+ provider=self.provider,
+ surface=self.surface,
+ id=internal.session_id,
+ provider_session_id=internal.session_id,
+ agent=harness.agent,
+ cwd=harness.cwd,
+ permissions=_resolved_permissions(harness, options),
+ model=options.model or harness.agent.model,
+ runtime_root=harness.runtime_root,
+ )
+
+ async def send(self, session: Session, turn: Turn, options: RunOptions) -> Run:
+ internal = self._require_internal(session)
+ model = options.model or turn.model or session.model
+ if model is None:
+ raise YokeError('opencode send needs a model ("provider/model").')
+ return await asyncio.to_thread(self._send, internal, turn, model, options)
+
+ async def stream(
+ self,
+ session: Session,
+ turn: Turn,
+ options: RunOptions,
+ ) -> AsyncIterator[Event]:
+ internal = self._require_internal(session)
+ model = options.model or turn.model or session.model
+ if model is None:
+ raise YokeError('opencode stream needs a model ("provider/model").')
+ loop = asyncio.get_running_loop()
+ queue: asyncio.Queue[Event | None] = asyncio.Queue()
+ user_on_event = options.on_event
+
+ def relay(event: Event) -> None:
+ if user_on_event is not None:
+ user_on_event(event)
+ loop.call_soon_threadsafe(queue.put_nowait, event)
+
+ # _send already emits every event through options.on_event as it
+ # happens (the DB-poll watchdog calls it live, turn by turn), so
+ # streaming just needs to relay those same events onto an asyncio
+ # queue instead of collecting them into a Run — no separate
+ # streaming implementation to keep in sync with _send's logic.
+ streaming_options = options.model_copy(update={"on_event": relay})
+ send_task = asyncio.ensure_future(
+ asyncio.to_thread(self._send, internal, turn, model, streaming_options)
+ )
+ send_task.add_done_callback(
+ lambda _task: loop.call_soon_threadsafe(queue.put_nowait, None)
+ )
+ try:
+ while True:
+ event = await queue.get()
+ if event is None:
+ break
+ yield event
+ finally:
+ run = await send_task
+ if run.status == RunStatus.FAILED:
+ message = run.failure.message if run.failure is not None else run.output
+ raise YokeError(message)
+
+ async def close(self, session: Session) -> None:
+ internal = self._sessions.pop(session.id, None)
+ if internal is None:
+ return
+ await asyncio.to_thread(self._release_process, internal.process)
+
+ # -- synchronous internals, bridged via asyncio.to_thread above --
+
+ def _require_internal(self, session: Session) -> _OpencodeSession:
+ internal = self._sessions.get(session.id)
+ if internal is None:
+ raise YokeError(f"no live opencode session for {session.id!r}")
+ return internal
+
+ def _retain_process(self, process: OpencodeServerProcess) -> None:
+ key = id(process)
+ self._process_refs[key] = self._process_refs.get(key, 0) + 1
+
+ def _release_process(self, process: OpencodeServerProcess) -> None:
+ key = id(process)
+ count = self._process_refs.get(key, 0)
+ if count <= 1:
+ self._process_refs.pop(key, None)
+ process.terminate()
+ deployment = self._deployments.pop(key, None)
+ if deployment is not None:
+ deployment.cleanup()
+ bridge = self._hook_bridges.pop(key, None)
+ if bridge is not None:
+ bridge.stop()
+ return
+ self._process_refs[key] = count - 1
+
+ def _check(self, cwd: Path, environment: dict[str, str]) -> Readiness:
+ try:
+ with self._brief_server(cwd, environment) as server:
+ providers = http.get_providers(
+ server.base_url, OPENCODE_CHECK_REQUEST_TIMEOUT_SECONDS
+ )
+ except FileNotFoundError:
+ return Readiness(
+ provider=self.provider,
+ surface=self.surface,
+ available=False,
+ message=OPENCODE_NOT_INSTALLED_MESSAGE,
+ fix="Install OpenCode: npm install -g opencode-ai",
+ )
+ except OpencodeServerStartupError as error:
+ return Readiness(
+ provider=self.provider,
+ surface=self.surface,
+ available=False,
+ message=str(error),
+ fix=OPENCODE_SERVER_REPAIR,
+ )
+ if len(providers) == 0:
+ return Readiness(
+ provider=self.provider,
+ surface=self.surface,
+ available=False,
+ message="no opencode providers are configured",
+ fix=OPENCODE_PROVIDER_REPAIR,
+ )
+ names = ", ".join(
+ string_field(item, "name") or string_field(item, "id") or "provider"
+ for item in providers
+ )
+ return Readiness(
+ provider=self.provider,
+ surface=self.surface,
+ available=True,
+ message=f"opencode providers configured: {names}",
+ )
+
+ def _models(self, cwd: Path, environment: dict[str, str]) -> tuple[Model, ...]:
+ with self._brief_server(cwd, environment) as server:
+ providers = http.get_providers(
+ server.base_url, OPENCODE_CHECK_REQUEST_TIMEOUT_SECONDS
+ )
+ models: list[Model] = []
+ for provider in providers:
+ provider_id = string_field(provider, "id")
+ if provider_id is None:
+ continue
+ entries = provider.get("models")
+ model_ids = _model_ids(entries)
+ for model_id in model_ids:
+ models.append(Model(id=f"{provider_id}/{model_id}", raw=provider))
+ return tuple(models)
+
+ def _brief_server(self, cwd: Path, environment: dict[str, str]):
+ return start_opencode_server(
+ self.command,
+ cwd,
+ OPENCODE_CHECK_STARTUP_TIMEOUT_SECONDS,
+ env=environment,
+ )
+
+ def _login(
+ self,
+ cwd: Path,
+ environment: dict[str, str],
+ provider_id: str,
+ api_key: str,
+ ) -> None:
+ server = start_opencode_server(
+ self.command,
+ cwd,
+ OPENCODE_CHECK_STARTUP_TIMEOUT_SECONDS,
+ env=environment,
+ )
+ try:
+ http.set_auth(
+ server.base_url,
+ provider_id,
+ api_key,
+ OPENCODE_CHECK_REQUEST_TIMEOUT_SECONDS,
+ )
+ finally:
+ server.terminate()
+
+ def _list_sessions(
+ self,
+ cwd: Path,
+ environment: dict[str, str],
+ limit: int | None,
+ directory: str | Path | None,
+ ) -> tuple[SessionSummary, ...]:
+ server = start_opencode_server(
+ self.command,
+ cwd,
+ OPENCODE_RUN_STARTUP_TIMEOUT_SECONDS,
+ env=environment,
+ )
+ try:
+ # Only filters by directory when a caller explicitly asks —
+ # preserves the existing global (unfiltered) default rather than
+ # silently narrowing every unscoped list_sessions() call to
+ # harness.cwd.
+ records = http.list_sessions(
+ server.base_url,
+ OPENCODE_RUN_REQUEST_TIMEOUT_SECONDS,
+ directory=str(directory) if directory is not None else None,
+ )
+ finally:
+ server.terminate()
+ if limit is not None:
+ records = records[:limit]
+ return tuple(
+ _session_summary(record, self.provider, self.surface) for record in records
+ )
+
+ def _read_session(
+ self,
+ cwd: Path,
+ environment: dict[str, str],
+ session_id: str,
+ include_messages: bool,
+ limit: int | None,
+ offset: int,
+ ) -> SessionHistory:
+ server = start_opencode_server(
+ self.command,
+ cwd,
+ OPENCODE_RUN_STARTUP_TIMEOUT_SECONDS,
+ env=environment,
+ )
+ try:
+ record = http.read_session(
+ server.base_url, session_id, OPENCODE_RUN_REQUEST_TIMEOUT_SECONDS
+ )
+ messages: tuple[SessionMessage, ...] = ()
+ if include_messages:
+ # Routed through the documented GET /session/:id/message API
+ # (session.messages) rather than the private SQLite schema —
+ # also the fix for limit/offset being accepted but silently
+ # ignored: OpenCode's own `limit` keeps the most *recent* N
+ # messages, which doesn't compose with offset over the full
+ # oldest-first order, so the full list is fetched and sliced
+ # locally instead.
+ entries = http.list_messages(
+ server.base_url, session_id, OPENCODE_RUN_REQUEST_TIMEOUT_SECONDS
+ )
+ if limit is not None:
+ entries = entries[offset : offset + limit]
+ else:
+ entries = entries[offset:]
+ messages = tuple(
+ _session_message(entry, self.provider, self.surface, session_id)
+ for entry in entries
+ )
+ finally:
+ server.terminate()
+ summary = _session_summary(record, self.provider, self.surface)
+ return SessionHistory(
+ provider=self.provider,
+ surface=self.surface,
+ session=summary,
+ messages=messages,
+ )
+
+ def _start_session(
+ self,
+ harness: Harness,
+ options: SessionOptions,
+ deployment: RuntimeDeployment,
+ ) -> _OpencodeSession:
+ environment = dict(harness.environment)
+ # May set deployment.opencode_config_dir for the first time (no
+ # skills/agents/MCP configured, only hooks) — run before the
+ # OPENCODE_CONFIG_DIR check below so it's picked up either way.
+ hook_bridge = self._maybe_deploy_hook_bridge(options, deployment)
+ if deployment.opencode_config_dir is not None:
+ environment["OPENCODE_CONFIG_DIR"] = str(deployment.opencode_config_dir)
+ if deployment.opencode_config_content is not None:
+ environment["OPENCODE_CONFIG_CONTENT"] = deployment.opencode_config_content
+ if hook_bridge is not None:
+ environment["YOKE_HOOK_BRIDGE_URL"] = hook_bridge.base_url
+ try:
+ server = start_opencode_server(
+ self.command,
+ harness.cwd,
+ OPENCODE_RUN_STARTUP_TIMEOUT_SECONDS,
+ env=environment,
+ )
+ except Exception:
+ # Regression: the bridge previously started (it must be up
+ # before spawn, since its URL goes into the child's env) but
+ # this whole try/except only ever wrapped the create_session
+ # call below — a startup failure (missing binary, port
+ # exhaustion, timeout) leaked the bridge's thread and listening
+ # socket for the life of the process.
+ if hook_bridge is not None:
+ hook_bridge.stop()
+ raise
+ try:
+ record = http.create_session(
+ server.base_url,
+ str(harness.cwd),
+ harness.agent.description or "yoke run",
+ OPENCODE_RUN_REQUEST_TIMEOUT_SECONDS,
+ permission=_session_permission_block(
+ _resolved_permissions(harness, options)
+ ),
+ )
+ session_id = string_field(record, "id")
+ if session_id is None:
+ raise OpencodeServerStartupError("opencode did not return a session id")
+ except Exception:
+ server.terminate()
+ if hook_bridge is not None:
+ hook_bridge.stop()
+ raise
+ if hook_bridge is not None:
+ self._hook_bridges[id(server)] = hook_bridge
+ return _OpencodeSession(
+ process=server,
+ session_id=session_id,
+ cwd=harness.cwd,
+ environment=environment or None,
+ db_path=self._resolve_db_path(),
+ instructions=harness.agent.instructions,
+ provider_options=opencode_options(options),
+ )
+
+ def _maybe_deploy_hook_bridge(
+ self,
+ options: SessionOptions,
+ deployment: RuntimeDeployment,
+ ) -> OpencodeHookBridge | None:
+ """Deploy the tool.execute.before hook plugin, only if opted into.
+
+ No caller-configured request_handler/policy means no plugin file
+ and no bridge server — zero overhead for sessions that don't use
+ hooks, matching how MCP/skills/agents only ever write files a
+ caller's Agent actually asked for.
+ """
+
+ handler = opencode_request_handler(opencode_options(options))
+ if handler is None:
+ return None
+ config_dir = deployment.root / "opencode_config"
+ plugin_path = config_dir / "plugin" / "yoke_tool_hook.js"
+ plugin_path.parent.mkdir(parents=True, exist_ok=True)
+ plugin_path.write_text(OPENCODE_HOOK_PLUGIN_SOURCE)
+ deployment.opencode_config_dir = config_dir
+ bridge = OpencodeHookBridge(resolve=self._resolve_hook_request)
+ bridge.start()
+ return bridge
+
+ def _resolve_hook_request(self, session_id: str, payload: JsonObject) -> Response:
+ internal = self._sessions.get(session_id)
+ # Per-turn override, matching the permission watchdog's own
+ # opencode_options_for_run(options)-or-session-level fallback — set
+ # for the duration of the turn currently in flight (_send), already
+ # falls back to the session-level handler internally when a turn
+ # doesn't set its own. None outside any active turn.
+ handler = internal.current_request_handler if internal is not None else None
+ event, response = resolve_hook_request(payload, handler)
+ if internal is not None and internal.current_emit is not None:
+ internal.current_emit(
+ event.model_copy(
+ update={"kind": EventKind.REQUEST_RESOLVED, "response": response}
+ )
+ )
+ return response
+
+ def _resolve_db_path(self) -> Path:
+ if self._db_path_override is not None:
+ return self._db_path_override
+ # Fixed, HOME-relative path confirmed live in the CodeAlmanac spike
+ # this adapter is ported from (2026-07-08) — not affected by
+ # OPENCODE_CONFIG_DIR, which only relocates skill/agent discovery.
+ return Path.home() / ".local" / "share" / "opencode" / "opencode.db"
+
+ def _send(
+ self,
+ internal: _OpencodeSession,
+ turn: Turn,
+ model: str,
+ options: RunOptions,
+ ) -> Run:
+ provider_id, model_id = split_opencode_model(model)
+ prompt = turn.prompt
+ # OpenCode's POST /session/:id/message has a documented `system`
+ # field (confirmed live, v1.17.15) — pass instructions through it
+ # instead of prepending them to the prompt text, so system and user
+ # content stay separate.
+ #
+ # Regression: this used to be sent only once, on the session's
+ # first turn, on the (wrong) assumption that OpenCode retains it
+ # session-side like a persistent system prompt. Confirmed live
+ # (2026-07-14): it does not — LLMRequest.prepare() reads `system`
+ # from the *current* message only, so a second turn sent without it
+ # runs with no system prompt at all, and every fork (which marked
+ # instructions_sent=True to avoid "re-sending") never got the
+ # instructions in the first place. Sent on every turn instead; no
+ # instructions_sent state needed anymore.
+ system = internal.instructions
+ events: list = []
+ on_event = options.on_event
+
+ def emit(event) -> None:
+ events.append(event)
+ if on_event is not None:
+ on_event(event)
+
+ # RunOptions.provider.opencode overrides the session-level handler
+ # for this turn only, same fallback order the permission watchdog
+ # already used — computed once here so hooks and permissions agree
+ # on which handler answers *this* turn instead of hooks silently
+ # keeping whatever handler was live at session-start time.
+ effective_options = (
+ opencode_options_for_run(options) or internal.provider_options
+ )
+ # Lets a live tool.execute.before hook call (OpencodeHookBridge,
+ # providers/opencode/hooks.py) emit its resolved event into *this*
+ # turn's stream, and resolve against *this* turn's handler — the
+ # bridge is a long-lived, process-scoped server (env-var-addressed
+ # at process spawn, shared across forks), so it can't bind to one
+ # turn's closures/options at construction time.
+ internal.current_emit = emit
+ internal.current_request_handler = opencode_request_handler(effective_options)
+ try:
+ return self._send_turn(
+ internal,
+ prompt,
+ system,
+ provider_id,
+ model_id,
+ options,
+ emit,
+ events,
+ effective_options,
+ )
+ finally:
+ internal.current_emit = None
+ internal.current_request_handler = None
+
+ def _send_turn(
+ self,
+ internal: _OpencodeSession,
+ prompt: str,
+ system: str | None,
+ provider_id: str,
+ model_id: str,
+ options: RunOptions,
+ emit: Callable[[Event], None],
+ events: list,
+ effective_options: OpencodeOptions | dict[str, object] | None,
+ ) -> Run:
+ # Emitted first and unconditionally, matching CodexAppServer's
+ # _send(): Run.provider_session_id scans events in reverse for the
+ # first one carrying provider_session_id, so callers (e.g.
+ # CodeAlmanac's transcript-ref linking) depend on this existing at
+ # all, not just on Session.provider_session_id being set.
+ emit(
+ Event(
+ kind=EventKind.PROVIDER_SESSION,
+ surface=Surface.OPENCODE_SERVER,
+ message=f"opencode provider session {internal.session_id}",
+ provider_session_id=internal.session_id,
+ )
+ )
+
+ watchdog = OpencodeProgressWatchdog(
+ db_path=internal.db_path,
+ root_session_id=internal.session_id,
+ on_event=emit,
+ poll_interval_seconds=self.poll_interval_seconds,
+ stuck_after_seconds=self.stuck_after_seconds,
+ known_sessions=internal.known_sessions,
+ seen_part_ids=internal.seen_part_ids,
+ )
+ stop_event = threading.Event()
+ watchdog_thread = threading.Thread(
+ target=watchdog.run, args=(stop_event,), daemon=True
+ )
+ watchdog_thread.start()
+
+ permission_watchdog = OpencodePermissionWatchdog(
+ base_url=internal.process.base_url,
+ session_id=internal.session_id,
+ on_event=emit,
+ request_handler=opencode_request_handler(effective_options),
+ poll_interval_seconds=self.poll_interval_seconds,
+ seen_permission_ids=internal.seen_permission_ids,
+ )
+ permission_thread = threading.Thread(
+ target=permission_watchdog.run, args=(stop_event,), daemon=True
+ )
+ permission_thread.start()
+
+ message_result: dict[str, JsonObject | Exception] = {}
+
+ def _post() -> None:
+ try:
+ message_result["response"] = http.post_message(
+ internal.process.base_url,
+ internal.session_id,
+ str(internal.cwd),
+ provider_id,
+ model_id,
+ prompt,
+ options.timeout_seconds or OPENCODE_RUN_REQUEST_TIMEOUT_SECONDS,
+ system=system,
+ )
+ except Exception as error: # noqa: BLE001 - surfaced below
+ message_result["error"] = error
+
+ sender_thread = threading.Thread(target=_post, daemon=True)
+ sender_thread.start()
+
+ while sender_thread.is_alive():
+ if watchdog.stuck_reason is not None:
+ # Unwinds and terminates the server below, killing the
+ # connection the sender thread is blocked on.
+ stop_event.set()
+ internal.process.terminate()
+ failure = classify_opencode_failure(
+ str(OpencodeStuckToolCallError(watchdog.stuck_reason))
+ )
+ return Run(
+ provider=self.provider,
+ surface=self.surface,
+ status=RunStatus.FAILED,
+ output=failure.message,
+ events=tuple(events),
+ failure=failure,
+ )
+ sender_thread.join(timeout=OPENCODE_STUCK_CHECK_INTERVAL_SECONDS)
+
+ stop_event.set()
+ watchdog_thread.join(timeout=self.poll_interval_seconds * 2 + 5)
+ permission_thread.join(timeout=self.poll_interval_seconds * 2 + 5)
+
+ if "error" in message_result:
+ error = message_result["error"]
+ failure = classify_opencode_failure(str(error))
+ return Run(
+ provider=self.provider,
+ surface=self.surface,
+ status=RunStatus.FAILED,
+ output=failure.message,
+ events=tuple(events),
+ failure=failure,
+ )
+ response = as_record(message_result["response"])
+ info = as_record(response.get("info"))
+ raw_parts = response.get("parts")
+ parts = (
+ [as_record(part) for part in raw_parts]
+ if isinstance(raw_parts, list)
+ else []
+ )
+ text = final_text_from_parts(parts)
+ usage = parse_opencode_usage(info.get("tokens"))
+ return Run(
+ provider=self.provider,
+ surface=self.surface,
+ status=RunStatus.SUCCEEDED,
+ output=text or "opencode completed",
+ events=tuple(events),
+ usage=usage,
+ )
+
+
+def _resolved_permissions(harness: Harness, options: SessionOptions) -> Permissions:
+ return options.permissions or harness.permissions or harness.agent.permissions
+
+
+def _session_permission_block(permissions: Permissions) -> tuple[JsonObject, ...]:
+ """Return the session-creation permission block for a resolved posture.
+
+ `Approval.ASK` is the only posture this adapter can honor with a live
+ signal now that `GET /permission` + `POST /permission/:id/reply` are
+ wired (OpencodePermissionWatchdog, providers/opencode/permissions.py,
+ confirmed live in a 2026-07-12 spike) — AUTO and NEVER both mean "don't
+ ask", matching Codex's own approval_policy() mapping
+ (providers/codex_app/policy.py).
+
+ `access`/`network` are also translated here, gating the same tool
+ categories `accessible_claude_tools()` (providers/claude.py) gates:
+ write/edit/apply_patch need WRITE or FULL, bash needs FULL, webfetch/
+ websearch need `network`. Without this, `Permissions(access=READ,
+ network=False, approval=NEVER)` produced a bare `* = allow` rule and the
+ session reported read-only/no-network while every tool actually ran.
+
+ Rule order matters and was confirmed live (2026-07-13 spike): OpenCode
+ resolves a later rule over an earlier one when both match, so the
+ wildcard base rule must come first and per-tool denies after it — a
+ deny listed *before* a trailing `* = allow` is silently overridden by
+ that later wildcard.
+ """
+
+ base_action = "ask" if permissions.approval is Approval.ASK else "allow"
+ rules: list[JsonObject] = [
+ {"permission": "*", "pattern": "*", "action": base_action}
+ ]
+ if permissions.access not in (Access.WRITE, Access.FULL):
+ for tool in ("write", "edit", "apply_patch"):
+ rules.append({"permission": tool, "pattern": "*", "action": "deny"})
+ if permissions.access is not Access.FULL:
+ rules.append({"permission": "bash", "pattern": "*", "action": "deny"})
+ if not permissions.network:
+ for tool in ("webfetch", "websearch"):
+ rules.append({"permission": tool, "pattern": "*", "action": "deny"})
+ return tuple(rules)
+
+
+def opencode_options(options: SessionOptions) -> OpencodeOptions | dict[str, object]:
+ if options.provider is None:
+ return {}
+ return options.provider.opencode
+
+
+def opencode_options_for_run(
+ options: RunOptions,
+) -> OpencodeOptions | dict[str, object] | None:
+ if options.provider is None:
+ return None
+ return options.provider.opencode
+
+
+def _model_ids(entries: object) -> tuple[str, ...]:
+ if isinstance(entries, dict):
+ return tuple(str(key) for key in entries)
+ if isinstance(entries, list):
+ ids = []
+ for entry in entries:
+ record = as_record(entry) if isinstance(entry, dict) else {}
+ model_id = string_field(record, "id") if record else None
+ if model_id is not None:
+ ids.append(model_id)
+ return tuple(ids)
+ return ()
+
+
+def _session_summary(
+ record: JsonObject,
+ provider: Provider,
+ surface: str,
+) -> SessionSummary:
+ session_id = string_field(record, "id") or ""
+ return SessionSummary(
+ provider=provider,
+ surface=surface,
+ id=session_id,
+ provider_session_id=session_id or None,
+ title=string_field(record, "title"),
+ raw=record,
+ )
+
+
+def _session_message(
+ entry: JsonObject,
+ provider: Provider,
+ surface: str,
+ session_id: str,
+) -> SessionMessage:
+ info = as_record(entry.get("info"))
+ return SessionMessage(
+ provider=provider,
+ surface=surface,
+ session_id=session_id,
+ id=string_field(info, "id"),
+ role=string_field(info, "role"),
+ raw=entry,
+ )
diff --git a/src/yoke/providers/runtime_deployment.py b/src/yoke/providers/runtime_deployment.py
index c97285e..a914f40 100644
--- a/src/yoke/providers/runtime_deployment.py
+++ b/src/yoke/providers/runtime_deployment.py
@@ -16,6 +16,7 @@
codex_agent_toml,
description_for,
instructions_for,
+ option_mapping,
slug,
)
from yoke.providers.codex_app.prompts import native_subagents
@@ -26,6 +27,7 @@
native_skill_text,
skill_directory_name,
)
+from yoke.providers.opencode.agents import opencode_agent_files
@dataclass(slots=True)
@@ -41,6 +43,8 @@ class RuntimeDeployment:
generated_skill_root: Path | None = None
claude_plugin_root: Path | None = None
claude_plugin_name: str | None = None
+ opencode_config_dir: Path | None = None
+ opencode_config_content: str | None = None
def cleanup(self) -> None:
"""Remove only this deployment, never authored files."""
@@ -74,6 +78,8 @@ def deploy_runtime(
try:
if provider is Provider.CODEX:
_write_codex(agent, deployment)
+ elif provider is Provider.OPENCODE:
+ _write_opencode(agent, deployment)
else:
_write_claude(agent, deployment)
return deployment
@@ -105,10 +111,16 @@ def reclaim_stale_deployments(parent: Path) -> None:
def runtime_owner_pid(name: str) -> int | None:
parts = name.split("-", 3)
- if len(parts) != 4 or parts[0] != "yoke" or parts[1] not in {
- Provider.CLAUDE.value,
- Provider.CODEX.value,
- }:
+ if (
+ len(parts) != 4
+ or parts[0] != "yoke"
+ or parts[1]
+ not in {
+ Provider.CLAUDE.value,
+ Provider.CODEX.value,
+ Provider.OPENCODE.value,
+ }
+ ):
return None
try:
pid = int(parts[2])
@@ -251,6 +263,68 @@ def _write_claude(agent: Agent, deployment: RuntimeDeployment) -> None:
deployment.claude_plugin_name = deployment.root.name
+def _write_opencode(agent: Agent, deployment: RuntimeDeployment) -> None:
+ # OpenCode discovers skills and custom agents from a config directory
+ # the same shape as its own `.opencode/` project convention
+ # (skills//SKILL.md, agents/.md), pointed at via
+ # OPENCODE_CONFIG_DIR — no files land in the user's real project, unlike
+ # a naive `.opencode/` write into harness.cwd.
+ config_dir = deployment.root / "opencode_config"
+ skills = config_dir / "skills"
+ wrote_skills = bool(_write_skills(inline_skills(agent), Provider.OPENCODE, skills))
+ wrote_agents = False
+ agent_files = opencode_agent_files(agent, directory="agents")
+ agent_paths = [file.path for file in agent_files]
+ if len(set(agent_paths)) != len(agent_paths):
+ # Mirrors _write_codex's own check: two subagent names that slugify
+ # to the same filename (e.g. "Code Review" and "code-review") would
+ # otherwise silently overwrite one another with no error.
+ raise YokeError(
+ "OpenCode subagents compile to colliding agents/.md paths"
+ )
+ for file in agent_files:
+ path = config_dir / file.path
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(file.text)
+ wrote_agents = True
+ wrote_plugins = False
+ for name, source in _opencode_plugin_sources(agent).items():
+ path = config_dir / "plugin" / f"{slug(name)}.js"
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(source)
+ wrote_plugins = True
+ if wrote_skills or wrote_agents or wrote_plugins:
+ deployment.opencode_config_dir = config_dir
+ mcp_servers = option_mapping(agent, "mcp_servers")
+ if mcp_servers:
+ # Inline config, not a file: OPENCODE_CONFIG_CONTENT is merged over
+ # every other config source (global, project, OPENCODE_CONFIG_DIR)
+ # at OpenCode's highest precedence, so this doesn't collide with the
+ # skills directory above or clobber a real project's opencode.json.
+ deployment.opencode_config_content = json.dumps({"mcp": mcp_servers})
+
+
+def _opencode_plugin_sources(agent: Agent) -> dict[str, str]:
+ """Return caller-supplied OpenCode plugin JS source, keyed by name.
+
+ Pure pass-through, same as mcp_servers: Yoke does not generate this
+ plugin's contents, only writes what `agent.options["opencode_plugins"]`
+ already contains into a file OpenCode auto-loads. The *generated*
+ tool.execute.before hook plugin (OpencodeHookBridge,
+ providers/opencode/hooks.py) is a different, session-options-driven
+ mechanism and writes its own separate file into the same directory.
+ """
+
+ value = agent.options.get("opencode_plugins")
+ if not isinstance(value, dict):
+ return {}
+ return {
+ str(name): source
+ for name, source in value.items()
+ if isinstance(name, str) and isinstance(source, str)
+ }
+
+
def _write_skills(
skills: tuple[Skill, ...],
provider: Provider,
diff --git a/src/yoke/surfaces.py b/src/yoke/surfaces.py
index 17c30d4..9c4b364 100644
--- a/src/yoke/surfaces.py
+++ b/src/yoke/surfaces.py
@@ -368,6 +368,8 @@ def feature_recipes(
def default_surface(provider: Provider) -> Surface:
if provider is Provider.CLAUDE:
return Surface.CLAUDE_PYTHON_SDK
+ if provider is Provider.OPENCODE:
+ return Surface.OPENCODE_SERVER
return Surface.CODEX_APP_SERVER
@@ -400,6 +402,7 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities:
(Provider.CODEX, Surface.CODEX_PYTHON_SDK): Channel.SDK,
(Provider.CODEX, Surface.CODEX_TYPESCRIPT_SDK): Channel.SDK,
(Provider.CODEX, Surface.CODEX_APP_SERVER): Channel.APP_SERVER,
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER): Channel.APP_SERVER,
}
@@ -411,6 +414,7 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities:
(Provider.CODEX, Surface.CODEX_PYTHON_SDK): "codex_app_server",
(Provider.CODEX, Surface.CODEX_TYPESCRIPT_SDK): "codex_sdk",
(Provider.CODEX, Surface.CODEX_APP_SERVER): "codex_app_server",
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER): "opencode_server",
}
@@ -448,6 +452,13 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities:
"https://developers.openai.com/codex/app-server",
"https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md",
),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER): (
+ "https://opencode.ai/docs/server/",
+ "https://opencode.ai/docs/skills/",
+ "https://opencode.ai/docs/mcp-servers/",
+ "https://opencode.ai/docs/config/",
+ "https://opencode.ai/docs/providers/",
+ ),
}
@@ -613,6 +624,61 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities:
"https://developers.openai.com/codex/app-server",
"https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md",
),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION_LIST): (
+ "https://opencode.ai/docs/server/",
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION_READ): (
+ "https://opencode.ai/docs/server/",
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION_RENAME): (
+ "https://opencode.ai/docs/server/",
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION_COMPACT): (
+ "https://opencode.ai/docs/server/",
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.FORK): (
+ "https://opencode.ai/docs/server/",
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.INTERRUPT): (
+ "https://opencode.ai/docs/server/",
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.MODELS): (
+ "https://opencode.ai/docs/server/",
+ "https://opencode.ai/docs/providers/",
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.LOGIN): (
+ "https://opencode.ai/docs/server/",
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SKILLS): (
+ "https://opencode.ai/docs/skills/",
+ "https://opencode.ai/docs/config/",
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.FILESYSTEM_AGENT): (
+ "https://opencode.ai/docs/agents/",
+ "https://opencode.ai/docs/config/",
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.DECLARED_SUBAGENTS): (
+ "https://opencode.ai/docs/agents/",
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.INLINE_SUBAGENTS): (
+ "https://opencode.ai/docs/server/",
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.STREAMING): (
+ "https://opencode.ai/docs/server/",
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.MCP): (
+ "https://opencode.ai/docs/mcp-servers/",
+ "https://opencode.ai/docs/config/",
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.PLUGINS): (
+ "https://opencode.ai/docs/plugins/",
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.HOOKS): (
+ "https://opencode.ai/docs/plugins/",
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.PERMISSIONS): (
+ "https://opencode.ai/docs/server/",
+ ),
}
@@ -766,6 +832,109 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities:
"Codex /goal is provider-native interactive behavior; Yoke's codex_cli "
"adapter remains a bounded codex exec wrapper."
),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION_LIST): (
+ "Harness.sessions() calls GET /session."
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION_READ): (
+ "Harness.read_session() calls GET /session/:id plus GET "
+ "/session/:id/message for stored history, sliced locally for "
+ "offset/limit since OpenCode's own `limit` keeps the most recent "
+ "N messages rather than the earliest N."
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION_RENAME): (
+ "Harness.rename_session() and Session.rename() call PATCH /session/:id."
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION_COMPACT): (
+ "Session.compact() calls POST /session/:id/summarize."
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.FORK): (
+ "Session.fork() calls POST /session/:id/fork, then re-applies the "
+ "parent's permission ruleset via PATCH /session/:id — confirmed "
+ "live that fork does not inherit it otherwise (a forked session "
+ "starts with no ruleset at all, i.e. default allow)."
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.INTERRUPT): (
+ "Session.interrupt() calls POST /session/:id/abort."
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.MODELS): (
+ "GET /config/providers lists provider/model pairs OpenCode can route to."
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.LOGIN): (
+ "harness.login('api_key', api_key=...) calls PUT /auth/:id. OAuth "
+ "authorize/callback exists in the API but is not wired in this "
+ "adapter yet."
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SKILLS): (
+ "Yoke skills render as SKILL.md files under a Yoke-owned deployment "
+ "directory; OPENCODE_CONFIG_DIR points OpenCode at it without "
+ "touching the user's real project."
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.FILESYSTEM_AGENT): (
+ "Direct Yoke subagents render as agents/.md markdown with "
+ "YAML frontmatter (description, mode: subagent, model, permission), "
+ "written under the same OPENCODE_CONFIG_DIR skills use. The "
+ "subagent's own access/network posture also compiles into a "
+ "permission: block — confirmed live that a per-agent `bash: deny` "
+ "genuinely blocks the tool for that agent specifically, mirroring "
+ "how codex_agent_toml() already sandboxes a subagent from its own "
+ "Permissions.access."
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.DECLARED_SUBAGENTS): (
+ "Same agents/.md files as FILESYSTEM_AGENT; OpenCode "
+ "auto-discovers them from OPENCODE_CONFIG_DIR, no explicit "
+ "registration call is needed."
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.INLINE_SUBAGENTS): (
+ "OpenCode's built-in `task` tool spawns a child session; Yoke "
+ "normalizes the spawn/settle lifecycle into AgentCall event payloads."
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.STREAMING): (
+ "Yoke polls OpenCode's own SQLite part/message tables while a turn is "
+ "in flight and emits events as new terminal parts appear; OpenCode's "
+ "SSE stream was found unreliable for this in a prior live spike."
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.MCP): (
+ "Yoke renders agent.options['mcp_servers'] as {\"mcp\": {...}} JSON "
+ "and passes it via OPENCODE_CONFIG_CONTENT, OpenCode's highest-"
+ "precedence config source. OpenCode has no runtime add-server "
+ "endpoint, so servers must be known before the session starts."
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.PLUGINS): (
+ "agent.options['opencode_plugins'] (name -> raw JS source) writes "
+ "plugin/.js files under OPENCODE_CONFIG_DIR; Yoke passes the "
+ "source through unmodified rather than generating or validating it."
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.HOOKS): (
+ "A generated tool.execute.before plugin calls a local HTTP bridge "
+ "(OpencodeHookBridge) that resolves each tool call through "
+ "ProviderOptions.opencode.request_handler/policy, the same "
+ "RequestPolicy/Response contract used for permissions — confirmed "
+ "live that argument mutation (Response.updated_input) and denial "
+ "both change what actually runs, not just what's reported."
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.PERMISSIONS): (
+ "Translates approval, access, and network: Permissions.approval="
+ "ASK passes an ask-all session permission block instead of "
+ "allow-all (OpencodePermissionWatchdog polls GET /permission and "
+ "resolves each pending request via POST /permission/:id/reply); "
+ "access/network gate write/edit/apply_patch, bash, and webfetch/"
+ "websearch per-tool, the same categories accessible_claude_tools() "
+ "gates for Claude. Re-applied via PATCH /session/:id on fork(), "
+ "since OpenCode's fork endpoint does not inherit the parent "
+ "session's ruleset."
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.REQUEST_CALLBACKS): (
+ "ProviderOptions.opencode.request_handler (or .policy, a "
+ "RequestPolicy) is called with the same (event, default) -> "
+ "Response contract as Codex app-server's request_handler."
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.REQUEST_EVENTS): (
+ "GET /permission lists pending permissions across sessions — "
+ "confirmed live and non-deprecated, unlike "
+ "/session/:id/permissions/:permissionID which this adapter used "
+ "to target. Polled on its own thread alongside the DB-poll "
+ "progress watchdog, same poll-not-SSE architecture."
+ ),
}
@@ -984,6 +1153,43 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities:
(Provider.CODEX, Surface.CODEX_APP_SERVER, Feature.EXPERIMENTAL_API): (
"CodexAppServerOptions(experimental_api=True)",
),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.ONE_SHOT): (
+ 'await Harness(provider="opencode", agent=agent, cwd=repo).run(prompt)',
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION): (
+ 'session = await Harness(provider="opencode", agent=agent, cwd=repo).start()',
+ "await session.run(prompt)",
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION_LIST): (
+ "sessions = await harness.sessions()",
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION_READ): (
+ "history = await harness.read_session(session_id)",
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION_COMPACT): (
+ "await session.compact()",
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION_RENAME): (
+ "renamed = await harness.rename_session(session_id, title)",
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.STREAMING): (
+ "RunOptions(on_event=callback)",
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.MODELS): (
+ "models = await harness.models()",
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.LOGIN): (
+ 'await harness.login("api_key", api_key=...)',
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.INTERRUPT): (
+ "await session.interrupt()",
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.FORK): (
+ "fork = await session.fork()",
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SKILLS): (
+ "Agent(skills=[Skill(name=..., instructions=...)])",
+ ),
}
@@ -1533,4 +1739,136 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities:
),
}
),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER): Capabilities.from_map(
+ {
+ Feature.ONE_SHOT: Support.NATIVE,
+ Feature.SESSION: Support.NATIVE,
+ Feature.SESSION_LIST: (Support.NATIVE, "GET /session."),
+ Feature.SESSION_READ: (
+ Support.NATIVE,
+ "GET /session/:id plus a read-only poll of OpenCode's own "
+ "message table.",
+ ),
+ Feature.SESSION_RESUME: (
+ Support.UNKNOWN,
+ "OpenCode sessions are addressed by id, but resuming a prior "
+ "session into a fresh Harness.start() call is unconfirmed.",
+ ),
+ Feature.SESSION_COMPACT: (Support.NATIVE, "POST /session/:id/summarize."),
+ Feature.SESSION_RENAME: (Support.NATIVE, "PATCH /session/:id."),
+ Feature.SESSION_TAG: (
+ Support.UNSUPPORTED,
+ "No documented session tag concept.",
+ ),
+ Feature.STREAMING: (
+ Support.EMULATED,
+ "Poll-based, not OpenCode's native SSE stream, which a prior "
+ "live spike found unreliable for this purpose.",
+ ),
+ Feature.RUN_EVENT_CALLBACKS: (
+ Support.EMULATED,
+ "RunOptions.on_event is driven by the DB-poll watchdog, not a "
+ "native provider event stream.",
+ ),
+ Feature.STRUCTURED_OUTPUT: (
+ Support.UNSUPPORTED,
+ "No documented schema-constrained output API.",
+ ),
+ Feature.MODELS: (Support.NATIVE, "GET /config/providers."),
+ Feature.LOGIN: (
+ Support.NATIVE,
+ "PUT /auth/:id sets api_key credentials; OAuth authorize/"
+ "callback exists in the API but is not wired in this adapter.",
+ ),
+ Feature.PERMISSIONS: (
+ Support.NATIVE,
+ "Permissions.approval=ASK passes an ask-all session "
+ "permission block (AUTO/NEVER still pass allow-all); "
+ "pending permissions are resolved live (see REQUEST_EVENTS).",
+ ),
+ Feature.REQUEST_CALLBACKS: (
+ Support.COMPILED,
+ "ProviderOptions.opencode.request_handler/policy answer "
+ "pending permissions the same RequestPolicy/Response "
+ "contract Codex app-server and Claude use.",
+ ),
+ Feature.CODEX_PERMISSIONS: Support.UNSUPPORTED,
+ Feature.CLAUDE_PERMISSIONS: Support.UNSUPPORTED,
+ Feature.FILESYSTEM_AGENT: (
+ Support.COMPILED,
+ "Direct Yoke subagents compile into agents/.md "
+ "markdown files with YAML frontmatter under the same "
+ "OPENCODE_CONFIG_DIR used for skills; nested subagents-of-"
+ "subagents are not compiled (OpenCode documents no nested "
+ "invocation model to target).",
+ ),
+ Feature.INLINE_SUBAGENTS: (
+ Support.NATIVE,
+ "The built-in `task` tool spawns a child session, discovered "
+ "and normalized by the DB-poll watchdog.",
+ ),
+ Feature.DECLARED_SUBAGENTS: (
+ Support.COMPILED,
+ "Yoke subagents compile to agents/.md with "
+ "`mode: subagent`; OpenCode auto-discovers them from "
+ "OPENCODE_CONFIG_DIR, invocation remains model-driven "
+ "(@mention or delegation).",
+ ),
+ Feature.COLLAB_AGENT_TOOLS: Support.UNSUPPORTED,
+ Feature.COLLABORATION_MODE: Support.UNSUPPORTED,
+ Feature.SKILLS: (
+ Support.NATIVE,
+ "OPENCODE_CONFIG_DIR points OpenCode at a Yoke-generated "
+ "skills//SKILL.md directory without touching the "
+ "user's real project.",
+ ),
+ Feature.PLUGINS: (
+ Support.COMPILED,
+ "agent.options['opencode_plugins'] (name -> raw JS source) "
+ "compiles into plugin/.js files under the same "
+ "OPENCODE_CONFIG_DIR skills/agents use — pure pass-through, "
+ "Yoke does not generate or validate the plugin's contents.",
+ ),
+ Feature.HOOKS: (
+ Support.COMPILED,
+ "A generated tool.execute.before plugin (OpencodeHookBridge) "
+ "relays each tool call to a local HTTP bridge, resolved via "
+ "ProviderOptions.opencode.request_handler/policy — confirmed "
+ "live: argument mutation and denial both actually change "
+ "what runs. The bridge itself is deployed only once, when a "
+ "handler/policy is configured at session start (a later "
+ "per-turn RunOptions.provider.opencode cannot retroactively "
+ "deploy it); once deployed, each turn's own "
+ "RunOptions.provider.opencode *does* override which handler "
+ "answers, same fallback order as permission resolution.",
+ ),
+ Feature.MCP: (
+ Support.COMPILED,
+ "agent.options['mcp_servers'] compiles into an "
+ "OPENCODE_CONFIG_CONTENT env var (`{\"mcp\": {...}}`), merged "
+ "over the user's own opencode.json at OpenCode's highest "
+ "config precedence — no runtime add-server API exists.",
+ ),
+ Feature.GOAL: Support.UNSUPPORTED,
+ Feature.GOAL_LOOP: Support.UNSUPPORTED,
+ Feature.MUTABLE_GOAL: Support.UNSUPPORTED,
+ Feature.READABLE_GOAL: Support.UNSUPPORTED,
+ Feature.INTERRUPT: (Support.NATIVE, "POST /session/:id/abort."),
+ Feature.FORK: (Support.NATIVE, "POST /session/:id/fork."),
+ Feature.WORKFLOW: (
+ Support.EMULATED,
+ "Yoke executes workflow steps as multiple session sends.",
+ ),
+ Feature.NATIVE_WORKFLOW: Support.UNSUPPORTED,
+ Feature.EXPERIMENTAL_API: Support.UNSUPPORTED,
+ Feature.REQUEST_EVENTS: (
+ Support.COMPILED,
+ "GET /permission (confirmed live, non-deprecated) is polled "
+ "the same way OpencodeProgressWatchdog polls SQLite; pending "
+ "permissions are resolved via POST /permission/:id/reply — "
+ "no SSE dependency needed, disproving this adapter's earlier "
+ "assumption that discovery required it.",
+ ),
+ }
+ ),
}
diff --git a/tests/test_opencode_agents.py b/tests/test_opencode_agents.py
new file mode 100644
index 0000000..49842d3
--- /dev/null
+++ b/tests/test_opencode_agents.py
@@ -0,0 +1,109 @@
+from __future__ import annotations
+
+from yoke import Agent
+from yoke.models import Access, Permissions
+from yoke.providers.opencode.agents import opencode_agent_files, opencode_agent_markdown
+
+
+def test_opencode_agent_files_compile_direct_subagents() -> None:
+ agent = Agent(
+ instructions="Coordinate the work.",
+ subagents={
+ "docs_researcher": Agent(
+ description="Documentation specialist.",
+ instructions="Use primary docs and return links.",
+ model="gpt-5.4-mini",
+ )
+ },
+ )
+
+ files = opencode_agent_files(agent)
+
+ assert len(files) == 1
+ assert files[0].name == "docs_researcher"
+ assert files[0].path.as_posix() == ".opencode/agents/docs_researcher.md"
+ assert files[0].text == opencode_agent_markdown(
+ "docs_researcher", agent.subagents["docs_researcher"]
+ )
+
+
+def test_opencode_agent_markdown_renders_frontmatter_and_body() -> None:
+ # Default Permissions() is access=READ, network=False, so the default
+ # subagent below also gets a permission: block — matching how
+ # codex_agent_toml() already sandboxes a default-permissions subagent
+ # to "read-only" rather than leaving it unrestricted.
+ subagent = Agent(
+ description="Documentation specialist.",
+ instructions="Use primary docs and return links.",
+ model="gpt-5.4-mini",
+ )
+
+ text = opencode_agent_markdown("docs_researcher", subagent)
+
+ assert text == (
+ "---\n"
+ 'description: "Documentation specialist."\n'
+ "mode: subagent\n"
+ 'model: "gpt-5.4-mini"\n'
+ "permission:\n"
+ " write: deny\n"
+ " edit: deny\n"
+ " apply_patch: deny\n"
+ " bash: deny\n"
+ " webfetch: deny\n"
+ " websearch: deny\n"
+ "---\n"
+ "\n"
+ "Use primary docs and return links.\n"
+ )
+
+
+def test_opencode_agent_markdown_omits_permission_block_for_full_access() -> None:
+ subagent = Agent(
+ instructions="do anything",
+ permissions=Permissions(access=Access.FULL, network=True),
+ )
+
+ text = opencode_agent_markdown("unrestricted", subagent)
+
+ assert "permission:" not in text
+
+
+def test_opencode_agent_markdown_write_access_allows_edit_but_not_bash() -> None:
+ subagent = Agent(
+ instructions="edit only",
+ permissions=Permissions(access=Access.WRITE, network=True),
+ )
+
+ text = opencode_agent_markdown("editor", subagent)
+
+ assert "permission:\n bash: deny\n" in text
+ assert "edit: deny" not in text
+ assert "write: deny" not in text
+
+
+def test_opencode_agent_markdown_falls_back_without_description_or_model() -> None:
+ subagent = Agent(instructions="Review the patch for bugs.")
+
+ text = opencode_agent_markdown("reviewer", subagent)
+
+ assert 'description: "Yoke subagent reviewer."' in text
+ assert "mode: subagent" in text
+ assert "model:" not in text
+ assert text.endswith("Review the patch for bugs.\n")
+
+
+def test_opencode_agent_files_ignores_nested_subagents_of_subagents() -> None:
+ agent = Agent(
+ instructions="root",
+ subagents={
+ "planner": Agent(
+ instructions="plan",
+ subagents={"nested": Agent(instructions="should not compile")},
+ )
+ },
+ )
+
+ files = opencode_agent_files(agent)
+
+ assert [file.name for file in files] == ["planner"]
diff --git a/tests/test_opencode_history.py b/tests/test_opencode_history.py
new file mode 100644
index 0000000..0ff458b
--- /dev/null
+++ b/tests/test_opencode_history.py
@@ -0,0 +1,157 @@
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass
+from pathlib import Path
+
+import pytest
+
+from yoke import Agent, Harness
+from yoke.errors import UnsupportedFeature
+from yoke.providers.opencode import http
+from yoke.providers.opencode_server import OpencodeServer
+
+
+@dataclass
+class _FakeProcess:
+ base_url: str = "http://127.0.0.1:0"
+
+ def terminate(self) -> None:
+ pass
+
+
+def _harness(tmp_path: Path) -> Harness:
+ return Harness(
+ provider="opencode",
+ agent=Agent(instructions="x"),
+ cwd=tmp_path,
+ )
+
+
+def test_list_sessions_rejects_cursor(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+) -> None:
+ monkeypatch.setattr(
+ "yoke.providers.opencode_server.start_opencode_server",
+ lambda *args, **kwargs: _FakeProcess(),
+ )
+ adapter = OpencodeServer()
+ with pytest.raises(UnsupportedFeature):
+ asyncio.run(adapter.list_sessions(_harness(tmp_path), cursor="abc"))
+
+
+def test_list_sessions_rejects_excluding_worktrees(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+) -> None:
+ monkeypatch.setattr(
+ "yoke.providers.opencode_server.start_opencode_server",
+ lambda *args, **kwargs: _FakeProcess(),
+ )
+ adapter = OpencodeServer()
+ with pytest.raises(UnsupportedFeature):
+ asyncio.run(
+ adapter.list_sessions(_harness(tmp_path), include_worktrees=False)
+ )
+
+
+def test_list_sessions_passes_explicit_cwd_as_directory_filter(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+) -> None:
+ monkeypatch.setattr(
+ "yoke.providers.opencode_server.start_opencode_server",
+ lambda *args, **kwargs: _FakeProcess(),
+ )
+ captured: dict[str, object] = {}
+
+ def fake_list_sessions(base_url, timeout, *, directory=None):
+ captured["directory"] = directory
+ return ({"id": "ses_1", "title": "one"},)
+
+ monkeypatch.setattr(http, "list_sessions", fake_list_sessions)
+ adapter = OpencodeServer()
+
+ result = asyncio.run(
+ adapter.list_sessions(_harness(tmp_path), cwd=tmp_path / "other")
+ )
+
+ assert captured["directory"] == str(tmp_path / "other")
+ assert result.sessions[0].id == "ses_1"
+
+
+def test_list_sessions_does_not_filter_by_directory_when_cwd_omitted(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+) -> None:
+ monkeypatch.setattr(
+ "yoke.providers.opencode_server.start_opencode_server",
+ lambda *args, **kwargs: _FakeProcess(),
+ )
+ captured: dict[str, object] = {}
+
+ def fake_list_sessions(base_url, timeout, *, directory=None):
+ captured["directory"] = directory
+ return ()
+
+ monkeypatch.setattr(http, "list_sessions", fake_list_sessions)
+ adapter = OpencodeServer()
+
+ asyncio.run(adapter.list_sessions(_harness(tmp_path)))
+
+ assert captured["directory"] is None
+
+
+def test_read_session_fetches_messages_via_the_history_api_and_slices_locally(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+) -> None:
+ monkeypatch.setattr(
+ "yoke.providers.opencode_server.start_opencode_server",
+ lambda *args, **kwargs: _FakeProcess(),
+ )
+ monkeypatch.setattr(
+ http, "read_session", lambda base_url, session_id, timeout: {"id": session_id}
+ )
+ entries = tuple(
+ {"info": {"id": f"msg_{i}", "role": "user"}} for i in range(5)
+ )
+ calls: list[str] = []
+
+ def fake_list_messages(base_url, session_id, timeout):
+ calls.append(session_id)
+ return entries
+
+ monkeypatch.setattr(http, "list_messages", fake_list_messages)
+ adapter = OpencodeServer()
+
+ history = asyncio.run(
+ adapter.read_session(
+ _harness(tmp_path), "ses_1", limit=2, offset=1
+ )
+ )
+
+ assert calls == ["ses_1"]
+ assert [m.id for m in history.messages] == ["msg_1", "msg_2"]
+
+
+def test_read_session_skips_messages_when_not_requested(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+) -> None:
+ monkeypatch.setattr(
+ "yoke.providers.opencode_server.start_opencode_server",
+ lambda *args, **kwargs: _FakeProcess(),
+ )
+ monkeypatch.setattr(
+ http, "read_session", lambda base_url, session_id, timeout: {"id": session_id}
+ )
+ called = []
+ monkeypatch.setattr(
+ http,
+ "list_messages",
+ lambda *args, **kwargs: called.append(1) or (),
+ )
+ adapter = OpencodeServer()
+
+ history = asyncio.run(
+ adapter.read_session(_harness(tmp_path), "ses_1", include_messages=False)
+ )
+
+ assert called == []
+ assert history.messages == ()
diff --git a/tests/test_opencode_hook_bridge_wiring.py b/tests/test_opencode_hook_bridge_wiring.py
new file mode 100644
index 0000000..3be927b
--- /dev/null
+++ b/tests/test_opencode_hook_bridge_wiring.py
@@ -0,0 +1,256 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+
+import pytest
+
+from yoke import Agent
+from yoke.models import EventKind, Turn
+from yoke.options import OpencodeOptions, ProviderOptions, RunOptions, SessionOptions
+from yoke.policies import RequestPolicy
+from yoke.providers.opencode import http
+from yoke.providers.opencode_server import OpencodeServer, _OpencodeSession
+from yoke.providers.runtime_deployment import deploy_runtime
+
+
+@dataclass
+class _FakeProcess:
+ base_url: str = "http://127.0.0.1:0"
+
+ def terminate(self) -> None:
+ pass
+
+
+def test_maybe_deploy_hook_bridge_returns_none_without_a_handler(
+ tmp_path: Path,
+) -> None:
+ adapter = OpencodeServer()
+ deployment = deploy_runtime(Agent(instructions="x"), "opencode", tmp_path)
+ try:
+ bridge = adapter._maybe_deploy_hook_bridge(SessionOptions(), deployment)
+ assert bridge is None
+ assert deployment.opencode_config_dir is None
+ finally:
+ deployment.cleanup()
+
+
+def test_maybe_deploy_hook_bridge_writes_the_plugin_and_starts_a_server(
+ tmp_path: Path,
+) -> None:
+ adapter = OpencodeServer()
+ deployment = deploy_runtime(Agent(instructions="x"), "opencode", tmp_path)
+ try:
+ options = SessionOptions(
+ provider=ProviderOptions(
+ opencode=OpencodeOptions(policy=RequestPolicy.allow_all())
+ )
+ )
+ bridge = adapter._maybe_deploy_hook_bridge(options, deployment)
+ assert bridge is not None
+ assert deployment.opencode_config_dir is not None
+ plugin_path = (
+ deployment.opencode_config_dir / "plugin" / "yoke_tool_hook.js"
+ )
+ assert plugin_path.is_file()
+ assert "tool.execute.before" in plugin_path.read_text()
+ assert bridge.base_url.startswith("http://127.0.0.1:")
+ finally:
+ bridge.stop()
+ deployment.cleanup()
+
+
+def test_resolve_hook_request_routes_to_the_turns_active_handler_and_emits(
+ tmp_path: Path,
+) -> None:
+ adapter = OpencodeServer()
+ events = []
+ internal = _OpencodeSession(
+ process=_FakeProcess(),
+ session_id="ses_test123",
+ cwd=Path.cwd(),
+ environment=None,
+ db_path=tmp_path / "opencode.db",
+ provider_options=OpencodeOptions(policy=RequestPolicy.allow_all()),
+ current_emit=events.append,
+ current_request_handler=RequestPolicy.deny_all("no"),
+ )
+ adapter._sessions["ses_test123"] = internal
+
+ response = adapter._resolve_hook_request(
+ "ses_test123",
+ {"sessionID": "ses_test123", "callID": "call_1", "tool": "bash", "args": {}},
+ )
+
+ assert response.decision == "deny"
+ assert len(events) == 1
+ assert events[0].kind == EventKind.REQUEST_RESOLVED
+ assert events[0].response is response
+
+
+def test_resolve_hook_request_ignores_session_level_policy_outside_a_turn(
+ tmp_path: Path,
+) -> None:
+ # Regression: _resolve_hook_request used to fall back to
+ # internal.provider_options (fixed at session-start) directly, so a
+ # per-turn RunOptions.provider.opencode override was silently ignored
+ # for hooks even though the identical override worked for permissions.
+ # Resolution now always goes through internal.current_request_handler,
+ # set fresh by _send() each turn — outside any turn it's None, and a
+ # hook call in that state defaults to allow rather than reaching for
+ # the stale session-level policy.
+ adapter = OpencodeServer()
+ internal = _OpencodeSession(
+ process=_FakeProcess(),
+ session_id="ses_test123",
+ cwd=Path.cwd(),
+ environment=None,
+ db_path=tmp_path / "opencode.db",
+ provider_options=OpencodeOptions(policy=RequestPolicy.deny_all("no")),
+ )
+ adapter._sessions["ses_test123"] = internal
+
+ response = adapter._resolve_hook_request(
+ "ses_test123",
+ {"sessionID": "ses_test123", "callID": "call_1", "tool": "bash", "args": {}},
+ )
+
+ assert response.decision == "allow"
+
+
+def test_resolve_hook_request_defaults_to_allow_for_an_unknown_session() -> None:
+ adapter = OpencodeServer()
+
+ response = adapter._resolve_hook_request(
+ "ses_unknown",
+ {"sessionID": "ses_unknown", "callID": "call_1", "tool": "bash", "args": {}},
+ )
+
+ assert response.decision == "allow"
+
+
+def test_send_intercepts_a_tool_call_via_a_live_hook_bridge(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # Exercises the real OpencodeHookBridge HTTP round trip (not a mocked
+ # http.list_permissions-style stub) by having the fake post_message
+ # itself act like the generated plugin: POST to the bridge's real
+ # base_url mid-turn, the way `tool.execute.before` would.
+ import httpx
+
+ captured_reply = {}
+
+ def fake_post_message(
+ base_url, session_id, cwd, provider_id, model_id, prompt, timeout, **kwargs
+ ):
+ reply = httpx.post(
+ f"{base_url}/tool-hook",
+ json={
+ "sessionID": session_id,
+ "callID": "call_1",
+ "tool": "bash",
+ "args": {"command": "echo hi"},
+ },
+ timeout=5,
+ ).json()
+ captured_reply.update(reply)
+ return {"info": {}, "parts": []}
+
+ monkeypatch.setattr(http, "post_message", fake_post_message)
+
+ adapter = OpencodeServer(poll_interval_seconds=0.01)
+ deployment = deploy_runtime(Agent(instructions="x"), "opencode", tmp_path)
+ try:
+ options = SessionOptions(
+ provider=ProviderOptions(
+ opencode=OpencodeOptions(
+ policy=RequestPolicy.deny_tools("shell", message="no shell")
+ )
+ )
+ )
+ bridge = adapter._maybe_deploy_hook_bridge(options, deployment)
+ assert bridge is not None
+ internal = _OpencodeSession(
+ process=type("P", (), {"base_url": bridge.base_url})(),
+ session_id="ses_hooktest",
+ cwd=tmp_path,
+ environment=None,
+ db_path=tmp_path / "opencode.db",
+ provider_options=OpencodeOptions(
+ policy=RequestPolicy.deny_tools("shell", message="no shell")
+ ),
+ )
+ adapter._sessions["ses_hooktest"] = internal
+
+ run = adapter._send(
+ internal, turn=Turn(prompt="hi"), model="openai/gpt-5", options=RunOptions()
+ )
+ finally:
+ bridge.stop()
+ deployment.cleanup()
+
+ assert captured_reply["deny"] is True
+ assert captured_reply["message"] == "no shell"
+ resolved = [e for e in run.events if e.kind == EventKind.REQUEST_RESOLVED]
+ assert len(resolved) == 1
+ assert resolved[0].response.decision == "deny"
+
+
+def test_start_session_stops_the_hook_bridge_when_server_startup_fails(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # Regression: _maybe_deploy_hook_bridge runs (and starts the bridge)
+ # before start_opencode_server, since the bridge's URL must go into the
+ # child process's env. Only the http.create_session call afterward was
+ # wrapped in a try/except that stopped the bridge — a startup failure
+ # (missing binary, port exhaustion, timeout) leaked the bridge's thread
+ # and listening socket for the life of the process.
+ import httpx
+
+ from yoke.providers.opencode.process import OpencodeServerStartupError
+
+ real_deploy = OpencodeServer._maybe_deploy_hook_bridge
+ deployed_bridges = []
+
+ def spying_deploy(self, options, deployment):
+ bridge = real_deploy(self, options, deployment)
+ deployed_bridges.append(bridge)
+ return bridge
+
+ def failing_start(*args, **kwargs):
+ raise OpencodeServerStartupError("opencode serve did not start")
+
+ monkeypatch.setattr(OpencodeServer, "_maybe_deploy_hook_bridge", spying_deploy)
+ monkeypatch.setattr(
+ "yoke.providers.opencode_server.start_opencode_server", failing_start
+ )
+
+ adapter = OpencodeServer()
+ deployment = deploy_runtime(Agent(instructions="x"), "opencode", tmp_path)
+ harness_options = SessionOptions(
+ provider=ProviderOptions(
+ opencode=OpencodeOptions(policy=RequestPolicy.allow_all())
+ )
+ )
+ from yoke import Harness
+
+ harness = Harness(
+ provider="opencode",
+ agent=Agent(instructions="x"),
+ cwd=tmp_path / "workspace",
+ )
+ try:
+ with pytest.raises(OpencodeServerStartupError):
+ adapter._start_session(harness, harness_options, deployment)
+ finally:
+ deployment.cleanup()
+
+ assert len(deployed_bridges) == 1
+ bridge = deployed_bridges[0]
+ assert bridge is not None
+ # If the bridge leaked, its ephemeral port would still be listening.
+ # bridge.stop() closes the socket, so a request against it now fails.
+ with pytest.raises(httpx.ConnectError):
+ httpx.post(f"{bridge.base_url}/tool-hook", json={}, timeout=2)
diff --git a/tests/test_opencode_hooks.py b/tests/test_opencode_hooks.py
new file mode 100644
index 0000000..91b5647
--- /dev/null
+++ b/tests/test_opencode_hooks.py
@@ -0,0 +1,122 @@
+from __future__ import annotations
+
+import httpx
+
+from yoke.models import EventKind, RequestKind, ToolKind
+from yoke.policies import RequestPolicy
+from yoke.providers.opencode.hooks import OpencodeHookBridge, hook_event, resolve
+
+_PAYLOAD = {
+ "sessionID": "ses_root",
+ "callID": "call_1",
+ "tool": "bash",
+ "args": {"command": "echo hi", "workdir": "/tmp"},
+}
+
+
+def test_hook_event_builds_provider_neutral_tool_request() -> None:
+ event = hook_event(_PAYLOAD)
+
+ assert event.kind == EventKind.TOOL_REQUEST
+ assert event.tool_id == "call_1"
+ assert event.tool_name == "bash"
+ assert event.tool is not None
+ assert event.tool.kind == ToolKind.SHELL
+ assert event.tool.command == "echo hi"
+ assert event.request is not None
+ assert event.request.kind == RequestKind.TOOL
+ assert event.source_thread_id == "ses_root"
+ # Opt-in-only feature: default is allow-unchanged, not deny.
+ assert event.response is not None
+ assert event.response.decision == "allow"
+
+
+def test_resolve_defaults_to_allow_without_a_handler() -> None:
+ event, response = resolve(_PAYLOAD, None)
+
+ assert response.decision == "allow"
+ assert response.updated_input is None
+
+
+def test_resolve_honors_a_deny_all_policy() -> None:
+ _, response = resolve(_PAYLOAD, RequestPolicy.deny_all("no bash"))
+
+ assert response.decision == "deny"
+ assert response.message == "no bash"
+
+
+def test_resolve_can_return_updated_input_from_a_raw_handler() -> None:
+ def handler(event, default):
+ from yoke.models import Response
+
+ return Response.allow(updated_input={"command": "echo modified"})
+
+ _, response = resolve(_PAYLOAD, handler)
+
+ assert response.decision == "allow"
+ assert response.updated_input == {"command": "echo modified"}
+
+
+def test_bridge_serializes_the_resolved_response_over_http() -> None:
+ captured = {}
+
+ def fake_resolve(session_id, payload):
+ from yoke.models import Response
+
+ captured["session_id"] = session_id
+ captured["payload"] = payload
+ return Response.allow(updated_input={"command": "echo modified"})
+
+ bridge = OpencodeHookBridge(resolve=fake_resolve)
+ bridge.start()
+ try:
+ response = httpx.post(f"{bridge.base_url}/tool-hook", json=_PAYLOAD, timeout=5)
+ assert response.status_code == 200
+ body = response.json()
+ assert body["args"] == {"command": "echo modified"}
+ assert body["deny"] is False
+ assert captured["session_id"] == "ses_root"
+ assert captured["payload"]["tool"] == "bash"
+ finally:
+ bridge.stop()
+
+
+def test_bridge_reports_deny_and_message_over_http() -> None:
+ def fake_resolve(session_id, payload):
+ from yoke.models import Response
+
+ return Response.deny("blocked")
+
+ bridge = OpencodeHookBridge(resolve=fake_resolve)
+ bridge.start()
+ try:
+ response = httpx.post(f"{bridge.base_url}/tool-hook", json=_PAYLOAD, timeout=5)
+ body = response.json()
+ assert body["deny"] is True
+ assert body["message"] == "blocked"
+ finally:
+ bridge.stop()
+
+
+def test_bridge_reports_a_visible_deny_instead_of_resetting_the_connection() -> None:
+ # Regression: a bug in a caller's request_handler (or a
+ # Response.updated_input value json.dumps can't serialize) used to
+ # propagate out of do_POST uncaught, resetting the connection with no
+ # response at all. The generated plugin's `await res.json()` then
+ # throws, which — per tool.execute.before's own semantics — blocks the
+ # tool call with no context and no REQUEST_RESOLVED event. A real HTTP
+ # 200 with an honest deny message is strictly better: still fails
+ # closed, but distinguishable from a genuine policy denial.
+ def broken_resolve(session_id, payload):
+ raise RuntimeError("handler exploded")
+
+ bridge = OpencodeHookBridge(resolve=broken_resolve)
+ bridge.start()
+ try:
+ response = httpx.post(f"{bridge.base_url}/tool-hook", json=_PAYLOAD, timeout=5)
+ assert response.status_code == 200
+ body = response.json()
+ assert body["deny"] is True
+ assert "handler exploded" in body["message"]
+ finally:
+ bridge.stop()
diff --git a/tests/test_opencode_parts.py b/tests/test_opencode_parts.py
new file mode 100644
index 0000000..6fb14ea
--- /dev/null
+++ b/tests/test_opencode_parts.py
@@ -0,0 +1,165 @@
+from __future__ import annotations
+
+from yoke.models import EventKind, ToolKind, ToolStatus
+from yoke.providers.opencode.parts import (
+ final_text_from_parts,
+ infer_opencode_tool_kind,
+ is_task_spawn,
+ map_opencode_part,
+)
+
+
+def test_text_part_maps_to_text_event() -> None:
+ events = map_opencode_part({"type": "text", "text": "hello"}, "session-1")
+
+ assert len(events) == 1
+ assert events[0].kind == EventKind.TEXT
+ assert events[0].message == "hello"
+
+
+def test_empty_text_part_maps_to_no_events() -> None:
+ assert map_opencode_part({"type": "text"}, "session-1") == ()
+
+
+def test_reasoning_part_maps_to_tool_summary() -> None:
+ events = map_opencode_part({"type": "reasoning", "text": "thinking"}, "session-1")
+
+ assert len(events) == 1
+ assert events[0].kind == EventKind.TOOL_SUMMARY
+
+
+def test_tool_part_maps_to_use_and_result_events() -> None:
+ part = {
+ "type": "tool",
+ "tool": "read",
+ "callID": "call-1",
+ "state": {
+ "status": "completed",
+ "input": {"filePath": "src/foo.py"},
+ "output": "contents",
+ },
+ }
+
+ events = map_opencode_part(part, "session-1")
+
+ assert len(events) == 2
+ use_event, result_event = events
+ assert use_event.kind == EventKind.TOOL_USE
+ assert use_event.tool.kind == ToolKind.READ
+ assert use_event.tool.path == "src/foo.py"
+ assert use_event.source_thread_id == "session-1"
+ assert result_event.kind == EventKind.TOOL_RESULT
+ assert result_event.tool_result == "contents"
+ assert result_event.tool_is_error is False
+
+
+def test_failed_tool_part_marks_result_as_error() -> None:
+ part = {
+ "type": "tool",
+ "tool": "bash",
+ "state": {"status": "error", "input": {}, "output": "boom"},
+ }
+
+ _, result_event = map_opencode_part(part, "session-1")
+
+ assert result_event.tool.status == ToolStatus.FAILED
+ assert result_event.tool_is_error is True
+
+
+def test_task_tool_part_carries_agent_call() -> None:
+ part = {
+ "type": "tool",
+ "tool": "task",
+ "state": {
+ "status": "completed",
+ "input": {"prompt": "do the thing"},
+ "metadata": {"sessionId": "child-1"},
+ },
+ }
+
+ use_event, result_event = map_opencode_part(part, "parent-1")
+
+ assert use_event.agent is not None
+ assert use_event.agent.new_thread_id == "child-1"
+ assert use_event.agent.sender_thread_id == "parent-1"
+ assert use_event.agent.prompt == "do the thing"
+ assert use_event.agent.action == "completed"
+ assert result_event.agent == use_event.agent
+
+
+def test_non_task_tool_part_has_no_agent_call() -> None:
+ part = {"type": "tool", "tool": "read", "state": {"status": "completed"}}
+
+ use_event, _ = map_opencode_part(part, "session-1")
+
+ assert use_event.agent is None
+
+
+def test_patch_part_maps_to_files_changed_summary() -> None:
+ events = map_opencode_part({"type": "patch", "files": ["a.py", "b.py"]}, "s")
+
+ assert len(events) == 1
+ assert "a.py" in events[0].message
+ assert "b.py" in events[0].message
+
+
+def test_empty_patch_part_maps_to_no_events() -> None:
+ assert map_opencode_part({"type": "patch", "files": []}, "s") == ()
+
+
+def test_step_finish_part_maps_to_context_usage() -> None:
+ part = {"type": "step-finish", "tokens": {"input": 10, "output": 20, "total": 30}}
+
+ events = map_opencode_part(part, "s")
+
+ assert len(events) == 1
+ assert events[0].kind == EventKind.CONTEXT_USAGE
+ assert events[0].usage.total_tokens == 30
+
+
+def test_unknown_part_type_maps_to_no_events() -> None:
+ assert map_opencode_part({"type": "unknown-thing"}, "s") == ()
+
+
+def test_infer_tool_kind_matches_common_tool_names() -> None:
+ assert infer_opencode_tool_kind("read") == ToolKind.READ
+ assert infer_opencode_tool_kind("bash") == ToolKind.SHELL
+ assert infer_opencode_tool_kind("glob") == ToolKind.SEARCH
+ assert infer_opencode_tool_kind("webfetch") == ToolKind.WEB
+ assert infer_opencode_tool_kind("task") == ToolKind.AGENT
+ assert infer_opencode_tool_kind("mystery") == ToolKind.UNKNOWN
+
+
+def test_final_text_from_parts_returns_last_text_part() -> None:
+ parts = [
+ {"type": "text", "text": "first"},
+ {"type": "tool", "tool": "read"},
+ {"type": "text", "text": "last"},
+ ]
+
+ assert final_text_from_parts(parts) == "last"
+
+
+def test_final_text_from_parts_returns_none_when_no_text() -> None:
+ assert final_text_from_parts([{"type": "tool", "tool": "read"}]) is None
+
+
+def test_is_task_spawn_detects_task_tool_with_session_id() -> None:
+ part = {
+ "type": "tool",
+ "tool": "task",
+ "state": {
+ "input": {"description": "spawn a helper"},
+ "metadata": {"sessionId": "child-9"},
+ },
+ }
+
+ result = is_task_spawn(part)
+
+ assert result == ("child-9", "spawn a helper")
+
+
+def test_is_task_spawn_returns_none_for_non_task_tool() -> None:
+ part = {"type": "tool", "tool": "read", "state": {}}
+
+ assert is_task_spawn(part) is None
diff --git a/tests/test_opencode_permission_approval.py b/tests/test_opencode_permission_approval.py
new file mode 100644
index 0000000..4d364f8
--- /dev/null
+++ b/tests/test_opencode_permission_approval.py
@@ -0,0 +1,264 @@
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass
+from pathlib import Path
+
+import pytest
+
+from yoke import Agent, Harness
+from yoke.models import Access, Approval, Permissions
+from yoke.options import ForkOptions, OpencodeOptions, ProviderOptions, SessionOptions
+from yoke.policies import RequestPolicy
+from yoke.providers.opencode import http
+from yoke.providers.opencode_server import (
+ OpencodeServer,
+ _OpencodeSession,
+ _resolved_permissions,
+ _session_permission_block,
+ opencode_options,
+ opencode_options_for_run,
+)
+
+
+def test_session_permission_block_asks_for_approval_permissions() -> None:
+ # Default Permissions() is access=READ, network=False, so the base "ask"
+ # rule is followed by denies for every tool that access/network don't
+ # cover — this is the fix for the reviewed gap where only `approval` was
+ # translated and `Permissions(access=READ, network=False, approval=ASK)`
+ # used to produce a bare `* = ask` with everything else still runnable.
+ assert _session_permission_block(Permissions(approval=Approval.ASK)) == (
+ {"permission": "*", "pattern": "*", "action": "ask"},
+ {"permission": "write", "pattern": "*", "action": "deny"},
+ {"permission": "edit", "pattern": "*", "action": "deny"},
+ {"permission": "apply_patch", "pattern": "*", "action": "deny"},
+ {"permission": "bash", "pattern": "*", "action": "deny"},
+ {"permission": "webfetch", "pattern": "*", "action": "deny"},
+ {"permission": "websearch", "pattern": "*", "action": "deny"},
+ )
+
+
+@pytest.mark.parametrize("approval", [Approval.AUTO, Approval.NEVER])
+def test_session_permission_block_allows_all_for_full_access_and_network(
+ approval: Approval,
+) -> None:
+ permissions = Permissions(
+ approval=approval, access=Access.FULL, network=True
+ )
+ assert _session_permission_block(permissions) == http.OPENCODE_ALLOW_ALL_PERMISSION
+
+
+@pytest.mark.parametrize("approval", [Approval.AUTO, Approval.NEVER])
+def test_session_permission_block_still_denies_by_access_when_not_asking(
+ approval: Approval,
+) -> None:
+ # AUTO/NEVER only mean "don't ask" — they must not also bypass the
+ # access/network-derived denies, which are a separate safety boundary.
+ permissions = Permissions(approval=approval, access=Access.READ, network=False)
+ block = _session_permission_block(permissions)
+ assert block[0] == {"permission": "*", "pattern": "*", "action": "allow"}
+ denied = {rule["permission"] for rule in block[1:]}
+ assert denied == {"write", "edit", "apply_patch", "bash", "webfetch", "websearch"}
+
+
+def test_session_permission_block_write_access_allows_edit_but_not_bash() -> None:
+ block = _session_permission_block(
+ Permissions(approval=Approval.NEVER, access=Access.WRITE, network=True)
+ )
+ denied = {rule["permission"] for rule in block[1:]}
+ assert denied == {"bash"}
+
+
+def test_resolved_permissions_prefers_run_then_harness_then_agent() -> None:
+ agent_permissions = Permissions(access=Access.READ)
+ harness_permissions = Permissions(access=Access.WRITE)
+ run_permissions = Permissions(access=Access.FULL)
+ harness = Harness(
+ provider="opencode",
+ agent=Agent(instructions="x", permissions=agent_permissions),
+ cwd=Path.cwd(),
+ permissions=harness_permissions,
+ )
+
+ assert _resolved_permissions(harness, SessionOptions()) is harness_permissions
+ assert (
+ _resolved_permissions(
+ harness, SessionOptions(permissions=run_permissions)
+ )
+ is run_permissions
+ )
+
+ bare_harness = Harness(
+ provider="opencode",
+ agent=Agent(instructions="x", permissions=agent_permissions),
+ cwd=Path.cwd(),
+ )
+ assert _resolved_permissions(bare_harness, SessionOptions()) is agent_permissions
+
+
+def test_opencode_options_helpers_read_provider_dot_opencode() -> None:
+ policy = RequestPolicy.allow_all()
+ session_options = SessionOptions(
+ provider=ProviderOptions(opencode=OpencodeOptions(policy=policy))
+ )
+ assert opencode_options(session_options).policy is policy
+ assert opencode_options(SessionOptions()) == {}
+ assert opencode_options_for_run(SessionOptions()) is None # type: ignore[arg-type]
+
+
+@dataclass
+class _FakeProcess:
+ base_url: str = "http://127.0.0.1:0"
+ terminated: bool = False
+
+ def terminate(self) -> None:
+ self.terminated = True
+
+
+def test_start_session_passes_ask_all_permission_block_for_approval_ask(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ captured: dict[str, object] = {}
+
+ monkeypatch.setattr(
+ "yoke.providers.opencode_server.start_opencode_server",
+ lambda *args, **kwargs: _FakeProcess(),
+ )
+
+ def fake_create_session(base_url, cwd_directory, title, timeout, *, permission):
+ captured["permission"] = permission
+ return {"id": "ses_test"}
+
+ monkeypatch.setattr(http, "create_session", fake_create_session)
+
+ permissions = Permissions(approval=Approval.ASK, access=Access.FULL, network=True)
+ harness = Harness(
+ provider="opencode",
+ agent=Agent(instructions="x", permissions=permissions),
+ cwd=tmp_path / "workspace",
+ )
+ adapter = OpencodeServer()
+ from yoke.providers.runtime_deployment import deploy_runtime
+
+ deployment = deploy_runtime(harness.agent, "opencode", tmp_path)
+ try:
+ adapter._start_session(harness, SessionOptions(), deployment)
+ finally:
+ deployment.cleanup()
+
+ assert captured["permission"] == http.OPENCODE_ASK_ALL_PERMISSION
+
+
+def test_fork_carries_over_the_parents_permission_policy(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ async def exercise() -> None:
+ adapter = OpencodeServer()
+ process = _FakeProcess()
+ policy = RequestPolicy.allow_all()
+
+ def start_session(harness, options, deployment):
+ return _OpencodeSession(
+ process=process,
+ session_id="parent",
+ cwd=harness.cwd,
+ environment=None,
+ db_path=tmp_path / "opencode.db",
+ provider_options=OpencodeOptions(policy=policy),
+ )
+
+ def fake_fork_session(base_url, session_id, timeout, message_id=None):
+ return {"id": "forked"}
+
+ adapter._start_session = start_session # type: ignore[method-assign]
+ monkeypatch.setattr(
+ "yoke.providers.opencode_server.http.fork_session", fake_fork_session
+ )
+ monkeypatch.setattr(
+ "yoke.providers.opencode_server.http.update_session_permission",
+ lambda *args, **kwargs: {},
+ )
+ harness = Harness(
+ provider="opencode",
+ agent=Agent(instructions="be helpful"),
+ cwd=tmp_path / "workspace",
+ runtime_root=tmp_path / "runtime",
+ )
+
+ parent = await adapter.start(harness, SessionOptions())
+ forked = await adapter.fork(parent, ForkOptions())
+
+ forked_internal = adapter._sessions[forked.id]
+ assert forked_internal.provider_options is not None
+ assert forked_internal.provider_options.policy is policy
+
+ await adapter.close(forked)
+ await adapter.close(parent)
+
+ asyncio.run(exercise())
+
+
+def test_fork_reapplies_the_parents_permission_ruleset(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ # Regression: OpenCode's POST /session/:id/fork does not inherit the
+ # parent's permission ruleset (confirmed live) — a forked session starts
+ # with none at all (default allow), silently dropping whatever
+ # restriction the parent enforced even though the returned
+ # Session.permissions still reports the parent's posture.
+ async def exercise() -> None:
+ adapter = OpencodeServer()
+ process = _FakeProcess()
+
+ def start_session(harness, options, deployment):
+ return _OpencodeSession(
+ process=process,
+ session_id="parent",
+ cwd=harness.cwd,
+ environment=None,
+ db_path=tmp_path / "opencode.db",
+ )
+
+ def fake_fork_session(base_url, session_id, timeout, message_id=None):
+ return {"id": "forked"}
+
+ captured: dict[str, object] = {}
+
+ def fake_update_permission(base_url, session_id, timeout, *, permission):
+ captured["session_id"] = session_id
+ captured["permission"] = permission
+ return {}
+
+ adapter._start_session = start_session # type: ignore[method-assign]
+ monkeypatch.setattr(
+ "yoke.providers.opencode_server.http.fork_session", fake_fork_session
+ )
+ monkeypatch.setattr(
+ "yoke.providers.opencode_server.http.update_session_permission",
+ fake_update_permission,
+ )
+ harness = Harness(
+ provider="opencode",
+ agent=Agent(
+ instructions="be helpful",
+ permissions=Permissions(
+ access=Access.READ, network=False, approval=Approval.NEVER
+ ),
+ ),
+ cwd=tmp_path / "workspace",
+ runtime_root=tmp_path / "runtime",
+ )
+
+ parent = await adapter.start(harness, SessionOptions())
+ forked = await adapter.fork(parent, ForkOptions())
+
+ assert captured["session_id"] == "forked"
+ assert captured["permission"] == _session_permission_block(parent.permissions)
+
+ await adapter.close(forked)
+ await adapter.close(parent)
+
+ asyncio.run(exercise())
diff --git a/tests/test_opencode_permissions.py b/tests/test_opencode_permissions.py
new file mode 100644
index 0000000..0cadaf6
--- /dev/null
+++ b/tests/test_opencode_permissions.py
@@ -0,0 +1,250 @@
+from __future__ import annotations
+
+import threading
+
+import pytest
+
+from yoke.models import EventKind, RequestKind, ToolKind, ToolStatus
+from yoke.policies import RequestPolicy
+from yoke.providers.opencode import http
+from yoke.providers.opencode.permissions import (
+ OpencodePermissionWatchdog,
+ permission_event,
+ policy_response,
+)
+
+_RECORD = {
+ "id": "per_abc",
+ "sessionID": "ses_root",
+ "permission": "bash",
+ "patterns": ["echo hello"],
+ "metadata": {"command": "echo hello"},
+ "always": ["echo *"],
+ "tool": {"messageID": "msg_1", "callID": "call_1"},
+}
+
+
+def test_permission_event_builds_provider_neutral_request() -> None:
+ event = permission_event(_RECORD, "per_abc")
+
+ assert event.kind == EventKind.APPROVAL_REQUEST
+ assert event.tool_id == "per_abc"
+ assert event.tool_name == "bash"
+ assert event.tool is not None
+ assert event.tool.kind == ToolKind.SHELL
+ assert event.tool.command == "echo hello"
+ assert event.tool.status == ToolStatus.STARTED
+ assert event.request is not None
+ assert event.request.kind == RequestKind.PERMISSION
+ assert event.request.id == "per_abc"
+ assert event.source_thread_id == "ses_root"
+ # Defaults to deny so an unresolved permission fails closed.
+ assert event.response is not None
+ assert event.response.decision == "deny"
+
+
+def test_policy_response_defaults_to_deny_without_a_handler() -> None:
+ event = permission_event(_RECORD, "per_abc")
+
+ response = policy_response(event, None)
+
+ assert response.decision == "deny"
+
+
+def test_policy_response_honors_an_allow_all_policy() -> None:
+ event = permission_event(_RECORD, "per_abc")
+
+ response = policy_response(event, RequestPolicy.allow_all())
+
+ assert response.decision == "allow"
+
+
+def test_policy_response_falls_back_to_default_when_handler_returns_none() -> None:
+ event = permission_event(_RECORD, "per_abc")
+
+ response = policy_response(event, lambda evt, default: None)
+
+ assert response.decision == "deny"
+
+
+def test_watchdog_resolves_a_pending_permission_and_emits_a_resolved_event(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ http, "list_permissions", lambda base_url, timeout: (_RECORD,)
+ )
+ replies: list[tuple[str, str]] = []
+ monkeypatch.setattr(
+ http,
+ "respond_permission",
+ lambda base_url, permission_id, reply, timeout, **kwargs: replies.append(
+ (permission_id, reply)
+ ),
+ )
+ events = []
+ watchdog = OpencodePermissionWatchdog(
+ base_url="http://127.0.0.1:0",
+ session_id="ses_root",
+ on_event=events.append,
+ request_handler=RequestPolicy.allow_all(),
+ )
+
+ watchdog._poll_once()
+
+ assert replies == [("per_abc", "once")]
+ assert len(events) == 1
+ assert events[0].kind == EventKind.REQUEST_RESOLVED
+ assert events[0].response is not None
+ assert events[0].response.decision == "allow"
+
+
+def test_watchdog_denies_by_default_and_replies_reject(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ http, "list_permissions", lambda base_url, timeout: (_RECORD,)
+ )
+ replies: list[tuple[str, str]] = []
+ monkeypatch.setattr(
+ http,
+ "respond_permission",
+ lambda base_url, permission_id, reply, timeout, **kwargs: replies.append(
+ (permission_id, reply)
+ ),
+ )
+ watchdog = OpencodePermissionWatchdog(
+ base_url="http://127.0.0.1:0",
+ session_id="ses_root",
+ on_event=lambda event: None,
+ request_handler=None,
+ )
+
+ watchdog._poll_once()
+
+ assert replies == [("per_abc", "reject")]
+
+
+def test_watchdog_ignores_permissions_from_other_sessions(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ http, "list_permissions", lambda base_url, timeout: (_RECORD,)
+ )
+ resolved = []
+ monkeypatch.setattr(
+ http,
+ "respond_permission",
+ lambda *args, **kwargs: resolved.append(args),
+ )
+ watchdog = OpencodePermissionWatchdog(
+ base_url="http://127.0.0.1:0",
+ session_id="ses_other",
+ on_event=lambda event: None,
+ request_handler=RequestPolicy.allow_all(),
+ )
+
+ watchdog._poll_once()
+
+ assert resolved == []
+
+
+def test_watchdog_does_not_re_resolve_an_already_seen_permission(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ http, "list_permissions", lambda base_url, timeout: (_RECORD,)
+ )
+ calls = []
+ monkeypatch.setattr(
+ http,
+ "respond_permission",
+ lambda *args, **kwargs: calls.append(args),
+ )
+ watchdog = OpencodePermissionWatchdog(
+ base_url="http://127.0.0.1:0",
+ session_id="ses_root",
+ on_event=lambda event: None,
+ request_handler=RequestPolicy.allow_all(),
+ seen_permission_ids={"per_abc"},
+ )
+
+ watchdog._poll_once()
+
+ assert calls == []
+
+
+def test_watchdog_run_stops_promptly_and_does_a_final_poll(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ polled = []
+ monkeypatch.setattr(
+ http,
+ "list_permissions",
+ lambda base_url, timeout: polled.append(1) or (),
+ )
+ watchdog = OpencodePermissionWatchdog(
+ base_url="http://127.0.0.1:0",
+ session_id="ses_root",
+ on_event=lambda event: None,
+ request_handler=None,
+ poll_interval_seconds=0.01,
+ )
+ stop_event = threading.Event()
+ stop_event.set()
+
+ watchdog.run(stop_event)
+
+ assert len(polled) == 1
+
+
+def test_watchdog_retries_a_permission_whose_reply_failed(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # Regression: the ID used to be added to `_seen` before the reply was
+ # attempted, so a transient respond_permission() failure was swallowed
+ # and the permission was never retried — the in-flight message stayed
+ # blocked until its own outer timeout instead of being re-resolved on
+ # the next poll tick.
+ monkeypatch.setattr(
+ http, "list_permissions", lambda base_url, timeout: (_RECORD,)
+ )
+ attempts = []
+
+ def flaky_respond(base_url, permission_id, reply, timeout, **kwargs):
+ attempts.append(permission_id)
+ if len(attempts) == 1:
+ raise RuntimeError("transient network error")
+
+ monkeypatch.setattr(http, "respond_permission", flaky_respond)
+ events = []
+ watchdog = OpencodePermissionWatchdog(
+ base_url="http://127.0.0.1:0",
+ session_id="ses_root",
+ on_event=events.append,
+ request_handler=RequestPolicy.allow_all(),
+ )
+
+ watchdog._poll_once()
+ assert attempts == ["per_abc"]
+ assert events == []
+
+ watchdog._poll_once()
+ assert attempts == ["per_abc", "per_abc"]
+ assert len(events) == 1
+
+
+def test_watchdog_swallows_transient_http_errors(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ def failing_list(base_url, timeout):
+ raise RuntimeError("connection reset")
+
+ monkeypatch.setattr(http, "list_permissions", failing_list)
+ watchdog = OpencodePermissionWatchdog(
+ base_url="http://127.0.0.1:0",
+ session_id="ses_root",
+ on_event=lambda event: None,
+ request_handler=None,
+ )
+
+ watchdog._poll_once() # must not raise
diff --git a/tests/test_opencode_progress.py b/tests/test_opencode_progress.py
new file mode 100644
index 0000000..aeb2a74
--- /dev/null
+++ b/tests/test_opencode_progress.py
@@ -0,0 +1,292 @@
+from __future__ import annotations
+
+import json
+import sqlite3
+import threading
+import time
+from pathlib import Path
+
+from yoke.models import EventKind
+from yoke.providers.opencode.progress import (
+ OpencodeProgressWatchdog,
+ OpencodeStuckToolCall,
+)
+
+
+def _make_db(path: Path) -> None:
+ connection = sqlite3.connect(path)
+ connection.execute(
+ "CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, data TEXT, "
+ "time_created INTEGER)"
+ )
+ connection.execute(
+ "CREATE TABLE part (id TEXT PRIMARY KEY, session_id TEXT, message_id TEXT, "
+ "data TEXT, time_created INTEGER)"
+ )
+ connection.commit()
+ connection.close()
+
+
+def _insert_part(
+ path: Path,
+ *,
+ part_id: str,
+ session_id: str,
+ message_id: str,
+ role: str,
+ part: dict,
+ seq: int,
+) -> None:
+ connection = sqlite3.connect(path)
+ connection.execute(
+ "INSERT OR IGNORE INTO message (id, session_id, data, time_created) "
+ "VALUES (?, ?, ?, ?)",
+ (message_id, session_id, json.dumps({"role": role}), seq),
+ )
+ connection.execute(
+ "INSERT INTO part (id, session_id, message_id, data, time_created) "
+ "VALUES (?, ?, ?, ?, ?)",
+ (part_id, session_id, message_id, json.dumps(part), seq),
+ )
+ connection.commit()
+ connection.close()
+
+
+def test_watchdog_emits_events_for_assistant_parts(tmp_path: Path) -> None:
+ db_path = tmp_path / "opencode.db"
+ _make_db(db_path)
+ _insert_part(
+ db_path,
+ part_id="p1",
+ session_id="root",
+ message_id="m1",
+ role="assistant",
+ part={"type": "text", "text": "hello"},
+ seq=1,
+ )
+
+ events = []
+ watchdog = OpencodeProgressWatchdog(
+ db_path=db_path,
+ root_session_id="root",
+ on_event=events.append,
+ poll_interval_seconds=0.01,
+ stuck_after_seconds=999,
+ )
+ watchdog._poll_once()
+
+ assert len(events) == 1
+ assert events[0].kind == EventKind.TEXT
+ assert events[0].message == "hello"
+
+
+def test_watchdog_ignores_user_authored_parts(tmp_path: Path) -> None:
+ db_path = tmp_path / "opencode.db"
+ _make_db(db_path)
+ _insert_part(
+ db_path,
+ part_id="p1",
+ session_id="root",
+ message_id="m1",
+ role="user",
+ part={"type": "text", "text": "prompt echo"},
+ seq=1,
+ )
+
+ events = []
+ watchdog = OpencodeProgressWatchdog(
+ db_path=db_path,
+ root_session_id="root",
+ on_event=events.append,
+ poll_interval_seconds=0.01,
+ )
+ watchdog._poll_once()
+
+ assert events == []
+
+
+def test_watchdog_does_not_reemit_seen_parts(tmp_path: Path) -> None:
+ db_path = tmp_path / "opencode.db"
+ _make_db(db_path)
+ _insert_part(
+ db_path,
+ part_id="p1",
+ session_id="root",
+ message_id="m1",
+ role="assistant",
+ part={"type": "text", "text": "hello"},
+ seq=1,
+ )
+
+ events = []
+ watchdog = OpencodeProgressWatchdog(
+ db_path=db_path,
+ root_session_id="root",
+ on_event=events.append,
+ poll_interval_seconds=0.01,
+ )
+ watchdog._poll_once()
+ watchdog._poll_once()
+
+ assert len(events) == 1
+
+
+def test_watchdog_does_not_reemit_prior_turn_parts_across_sessions(
+ tmp_path: Path,
+) -> None:
+ # Regression: a live multi-turn smoke test found that a fresh watchdog
+ # per send() re-polled the whole session and re-emitted an earlier
+ # turn's already-seen parts as if they were new. Sharing known_sessions/
+ # seen_part_ids across watchdog instances (as opencode_server.py's
+ # per-session _OpencodeSession does) must prevent that.
+ db_path = tmp_path / "opencode.db"
+ _make_db(db_path)
+ _insert_part(
+ db_path,
+ part_id="turn1-part",
+ session_id="root",
+ message_id="m1",
+ role="assistant",
+ part={"type": "text", "text": "turn one output"},
+ seq=1,
+ )
+
+ known_sessions: set[str] = set()
+ seen_part_ids: set[str] = set()
+ first_turn_events = []
+ first_watchdog = OpencodeProgressWatchdog(
+ db_path=db_path,
+ root_session_id="root",
+ on_event=first_turn_events.append,
+ poll_interval_seconds=0.01,
+ known_sessions=known_sessions,
+ seen_part_ids=seen_part_ids,
+ )
+ first_watchdog._poll_once()
+ assert len(first_turn_events) == 1
+
+ _insert_part(
+ db_path,
+ part_id="turn2-part",
+ session_id="root",
+ message_id="m2",
+ role="assistant",
+ part={"type": "text", "text": "turn two output"},
+ seq=2,
+ )
+
+ second_turn_events = []
+ second_watchdog = OpencodeProgressWatchdog(
+ db_path=db_path,
+ root_session_id="root",
+ on_event=second_turn_events.append,
+ poll_interval_seconds=0.01,
+ known_sessions=known_sessions,
+ seen_part_ids=seen_part_ids,
+ )
+ second_watchdog._poll_once()
+
+ assert len(second_turn_events) == 1
+ assert second_turn_events[0].message == "turn two output"
+
+
+def test_watchdog_discovers_child_session_from_task_spawn(tmp_path: Path) -> None:
+ db_path = tmp_path / "opencode.db"
+ _make_db(db_path)
+ _insert_part(
+ db_path,
+ part_id="p1",
+ session_id="root",
+ message_id="m1",
+ role="assistant",
+ part={
+ "type": "tool",
+ "tool": "task",
+ "state": {
+ "status": "completed",
+ "input": {"prompt": "help"},
+ "metadata": {"sessionId": "child-1"},
+ },
+ },
+ seq=1,
+ )
+ _insert_part(
+ db_path,
+ part_id="p2",
+ session_id="child-1",
+ message_id="m2",
+ role="assistant",
+ part={"type": "text", "text": "child output"},
+ seq=2,
+ )
+
+ events = []
+ watchdog = OpencodeProgressWatchdog(
+ db_path=db_path,
+ root_session_id="root",
+ on_event=events.append,
+ poll_interval_seconds=0.01,
+ )
+ watchdog._poll_once()
+ # Child session was discovered mid-poll; a second pass picks up its parts.
+ watchdog._poll_once()
+
+ assert "child-1" in watchdog._known_sessions
+ child_texts = [e.message for e in events if e.kind == EventKind.TEXT]
+ assert "child output" in child_texts
+
+
+def test_watchdog_detects_stuck_tool_call(tmp_path: Path) -> None:
+ db_path = tmp_path / "opencode.db"
+ _make_db(db_path)
+ stale_start_ms = int(time.time() * 1000) - 1_000_000
+ _insert_part(
+ db_path,
+ part_id="p1",
+ session_id="root",
+ message_id="m1",
+ role="assistant",
+ part={
+ "type": "tool",
+ "tool": "bash",
+ "state": {
+ "status": "running",
+ "time": {"start": stale_start_ms},
+ "input": {"command": "sleep 999"},
+ },
+ },
+ seq=1,
+ )
+
+ events = []
+ watchdog = OpencodeProgressWatchdog(
+ db_path=db_path,
+ root_session_id="root",
+ on_event=events.append,
+ poll_interval_seconds=0.01,
+ stuck_after_seconds=1,
+ )
+ watchdog._poll_once()
+
+ assert isinstance(watchdog.stuck_reason, OpencodeStuckToolCall)
+ assert watchdog.stuck_reason.tool_name == "bash"
+ assert events == []
+
+
+def test_watchdog_run_stops_on_stop_event(tmp_path: Path) -> None:
+ db_path = tmp_path / "opencode.db"
+ _make_db(db_path)
+
+ watchdog = OpencodeProgressWatchdog(
+ db_path=db_path,
+ root_session_id="root",
+ on_event=lambda event: None,
+ poll_interval_seconds=0.01,
+ )
+ stop_event = threading.Event()
+ thread = threading.Thread(target=watchdog.run, args=(stop_event,))
+ thread.start()
+ stop_event.set()
+ thread.join(timeout=1)
+
+ assert not thread.is_alive()
diff --git a/tests/test_opencode_provider_surface.py b/tests/test_opencode_provider_surface.py
new file mode 100644
index 0000000..83b0c72
--- /dev/null
+++ b/tests/test_opencode_provider_surface.py
@@ -0,0 +1,59 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from yoke import Agent, Channel, Harness, Provider, Session, Surface, adapter_for
+from yoke.surfaces import report_for
+
+
+def test_harness_accepts_opencode_provider_and_surface() -> None:
+ harness = Harness(
+ provider="opencode",
+ surface="opencode_server",
+ agent=Agent(instructions="test"),
+ cwd=Path.cwd(),
+ )
+
+ assert harness.provider is Provider.OPENCODE
+ assert harness.surface is Surface.OPENCODE_SERVER
+ assert adapter_for(harness.provider, harness.surface).surface == "opencode_server"
+
+
+def test_harness_accepts_opencode_surface_aliases() -> None:
+ for alias in ("server", "app", "app_server", "http"):
+ harness = Harness(
+ provider="opencode",
+ surface=alias,
+ agent=Agent(instructions="test"),
+ cwd=Path.cwd(),
+ )
+ assert harness.surface is Surface.OPENCODE_SERVER
+
+
+def test_session_accepts_opencode_provider_surface() -> None:
+ session = Session(provider="opencode", surface="app", id="abc")
+
+ assert session.provider is Provider.OPENCODE
+ assert session.surface is Surface.OPENCODE_SERVER
+
+
+def test_opencode_default_surface_resolves_without_explicit_surface() -> None:
+ harness = Harness(
+ provider="opencode",
+ agent=Agent(instructions="test"),
+ cwd=Path.cwd(),
+ )
+
+ assert harness.surface is None
+ assert harness.require("models").surface is Surface.OPENCODE_SERVER
+
+
+def test_opencode_surface_report_is_app_server_channel_and_runnable() -> None:
+ report = report_for(Provider.OPENCODE, Surface.OPENCODE_SERVER)
+
+ assert report.channel == str(Channel.APP_SERVER)
+ assert report.runnable is True
+ features = {row.feature: row.support for row in report.features}
+ assert features["session"] == "native"
+ assert features["streaming"] == "emulated"
+ assert features["request_events"] == "compiled"
diff --git a/tests/test_opencode_runtime_deployment.py b/tests/test_opencode_runtime_deployment.py
new file mode 100644
index 0000000..a8f55be
--- /dev/null
+++ b/tests/test_opencode_runtime_deployment.py
@@ -0,0 +1,383 @@
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+
+import pytest
+
+from yoke import Agent, Harness, Skill
+from yoke.errors import YokeError
+from yoke.options import ForkOptions, SessionOptions
+from yoke.providers.opencode import http
+from yoke.providers.opencode_server import OpencodeServer, _OpencodeSession
+from yoke.providers.runtime_deployment import deploy_runtime
+
+
+def agent_with_skill() -> Agent:
+ return Agent(
+ instructions="be helpful",
+ skills=(Skill.from_text("check sources", name="sources"),),
+ )
+
+
+class FakeOpencodeProcess:
+ def __init__(self) -> None:
+ self.base_url = "http://127.0.0.1:0"
+ self.terminated = False
+
+ def terminate(self) -> None:
+ self.terminated = True
+
+
+def test_opencode_runtime_compiles_native_skill(tmp_path: Path) -> None:
+ deployment = deploy_runtime(agent_with_skill(), "opencode", tmp_path)
+ try:
+ assert deployment.opencode_config_dir is not None
+ skill_file = deployment.opencode_config_dir / "skills" / "sources" / "SKILL.md"
+ assert skill_file.is_file()
+ finally:
+ deployment.cleanup()
+ assert not deployment.root.exists()
+
+
+def test_opencode_runtime_skips_config_dir_without_skills(tmp_path: Path) -> None:
+ deployment = deploy_runtime(Agent(instructions="none"), "opencode", tmp_path)
+ try:
+ assert deployment.opencode_config_dir is None
+ finally:
+ deployment.cleanup()
+
+
+def test_opencode_runtime_writes_caller_supplied_plugin_source(
+ tmp_path: Path,
+) -> None:
+ agent = Agent(
+ instructions="x",
+ options={
+ "opencode_plugins": {
+ "my_plugin": "export const MyPlugin = async () => ({});"
+ }
+ },
+ )
+ deployment = deploy_runtime(agent, "opencode", tmp_path)
+ try:
+ assert deployment.opencode_config_dir is not None
+ plugin_path = deployment.opencode_config_dir / "plugin" / "my_plugin.js"
+ assert plugin_path.is_file()
+ assert plugin_path.read_text() == "export const MyPlugin = async () => ({});"
+ finally:
+ deployment.cleanup()
+
+
+def test_opencode_runtime_ignores_non_string_plugin_entries(tmp_path: Path) -> None:
+ agent = Agent(
+ instructions="x",
+ options={"opencode_plugins": {"bad": 123, "ok": "export const X = 1;"}},
+ )
+ deployment = deploy_runtime(agent, "opencode", tmp_path)
+ try:
+ assert deployment.opencode_config_dir is not None
+ plugin_dir = deployment.opencode_config_dir / "plugin"
+ assert not (plugin_dir / "bad.js").exists()
+ assert (plugin_dir / "ok.js").is_file()
+ finally:
+ deployment.cleanup()
+
+
+def test_opencode_runtime_compiles_mcp_servers_into_config_content(
+ tmp_path: Path,
+) -> None:
+ agent = Agent(
+ instructions="be helpful",
+ options={
+ "mcp_servers": {
+ "docs": {
+ "type": "remote",
+ "url": "https://developers.example.com/mcp",
+ }
+ }
+ },
+ )
+ deployment = deploy_runtime(agent, "opencode", tmp_path)
+ try:
+ assert deployment.opencode_config_content == (
+ '{"mcp": {"docs": {"type": "remote", '
+ '"url": "https://developers.example.com/mcp"}}}'
+ )
+ finally:
+ deployment.cleanup()
+
+
+def test_opencode_runtime_skips_config_content_without_mcp_servers(
+ tmp_path: Path,
+) -> None:
+ deployment = deploy_runtime(agent_with_skill(), "opencode", tmp_path)
+ try:
+ assert deployment.opencode_config_content is None
+ finally:
+ deployment.cleanup()
+
+
+def test_opencode_runtime_compiles_direct_subagent_as_filesystem_agent(
+ tmp_path: Path,
+) -> None:
+ agent = Agent(
+ instructions="root",
+ subagents={
+ "reviewer": Agent(
+ description="Find correctness issues.",
+ instructions="Review the patch for bugs.",
+ )
+ },
+ )
+ deployment = deploy_runtime(agent, "opencode", tmp_path)
+ try:
+ assert deployment.opencode_config_dir is not None
+ agent_file = deployment.opencode_config_dir / "agents" / "reviewer.md"
+ assert agent_file.is_file()
+ text = agent_file.read_text()
+ assert 'description: "Find correctness issues."' in text
+ assert "mode: subagent" in text
+ assert "Review the patch for bugs." in text
+ finally:
+ deployment.cleanup()
+
+
+def test_opencode_runtime_sets_config_dir_for_subagents_without_skills(
+ tmp_path: Path,
+) -> None:
+ agent = Agent(
+ instructions="root",
+ subagents={"reviewer": Agent(instructions="review")},
+ )
+ deployment = deploy_runtime(agent, "opencode", tmp_path)
+ try:
+ assert deployment.opencode_config_dir is not None
+ finally:
+ deployment.cleanup()
+
+
+def test_opencode_runtime_rejects_colliding_subagent_filenames(tmp_path: Path) -> None:
+ # Regression: two subagent names that slugify to the same
+ # agents/.md path used to silently overwrite one another with no
+ # error — _write_codex already guards the equivalent case for Codex.
+ agent = Agent(
+ instructions="root",
+ subagents={
+ "code review": Agent(instructions="a"),
+ "code-review": Agent(instructions="b"),
+ },
+ )
+ with pytest.raises(YokeError):
+ deploy_runtime(agent, "opencode", tmp_path)
+
+
+def test_opencode_shared_process_keeps_runtime_until_last_release(
+ tmp_path: Path,
+) -> None:
+ adapter = OpencodeServer()
+ process = FakeOpencodeProcess()
+ deployment = deploy_runtime(agent_with_skill(), "opencode", tmp_path)
+ adapter._deployments[id(process)] = deployment
+ adapter._retain_process(process)
+ adapter._retain_process(process)
+
+ adapter._release_process(process)
+ assert deployment.root.exists()
+ assert not process.terminated
+
+ adapter._release_process(process)
+ assert not deployment.root.exists()
+ assert process.terminated
+
+
+def test_opencode_adapter_cleans_runtime_on_close(tmp_path: Path) -> None:
+ async def exercise() -> None:
+ adapter = OpencodeServer()
+ process = FakeOpencodeProcess()
+
+ def start_session(harness, options, deployment):
+ return _OpencodeSession(
+ process=process,
+ session_id="session-1",
+ cwd=harness.cwd,
+ environment=None,
+ db_path=tmp_path / "opencode.db",
+ instructions=harness.agent.instructions,
+ )
+
+ adapter._start_session = start_session # type: ignore[method-assign]
+ runtime_root = tmp_path / "runtime"
+ harness = Harness(
+ provider="opencode",
+ agent=agent_with_skill(),
+ cwd=tmp_path / "workspace",
+ runtime_root=runtime_root,
+ )
+
+ session = await adapter.start(harness, SessionOptions())
+ assert list(runtime_root.iterdir())
+ assert adapter._deployments
+
+ await adapter.close(session)
+ assert not list(runtime_root.iterdir())
+ assert process.terminated
+
+ asyncio.run(exercise())
+
+
+def test_opencode_closing_a_fork_does_not_delete_the_parents_live_runtime(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ # Regression: fork() used to store `deployment=None` on the forked
+ # session's own dataclass field, and close() cleaned up whichever
+ # session object happened to hold a deployment reference. Closing a
+ # fork while the parent (sharing the same live process) was still open
+ # worked by accident only because the fork's own deployment was None;
+ # closing the *parent* first while a fork was still open would delete
+ # the skills directory the still-running shared process depends on.
+ # Deployment cleanup must be tied to the process refcount, not to
+ # either session object.
+ async def exercise() -> None:
+ adapter = OpencodeServer()
+ process = FakeOpencodeProcess()
+
+ def start_session(harness, options, deployment):
+ return _OpencodeSession(
+ process=process,
+ session_id="parent",
+ cwd=harness.cwd,
+ environment=None,
+ db_path=tmp_path / "opencode.db",
+ instructions=harness.agent.instructions,
+ )
+
+ def fake_fork_session(base_url, session_id, timeout, message_id=None):
+ return {"id": "forked"}
+
+ adapter._start_session = start_session # type: ignore[method-assign]
+ monkeypatch.setattr(
+ "yoke.providers.opencode_server.http.fork_session", fake_fork_session
+ )
+ monkeypatch.setattr(
+ "yoke.providers.opencode_server.http.update_session_permission",
+ lambda *args, **kwargs: {},
+ )
+ runtime_root = tmp_path / "runtime"
+ harness = Harness(
+ provider="opencode",
+ agent=agent_with_skill(),
+ cwd=tmp_path / "workspace",
+ runtime_root=runtime_root,
+ )
+
+ parent = await adapter.start(harness, SessionOptions())
+ forked = await adapter.fork(parent, ForkOptions())
+ assert list(runtime_root.iterdir())
+
+ # Close the parent first — the fork is still open and shares the
+ # same process, so the runtime must survive.
+ await adapter.close(parent)
+ assert list(runtime_root.iterdir())
+ assert not process.terminated
+
+ await adapter.close(forked)
+ assert not list(runtime_root.iterdir())
+ assert process.terminated
+
+ asyncio.run(exercise())
+
+
+def test_opencode_fork_carries_over_parent_instructions(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ # Regression: fork() previously omitted `instructions` entirely, so a
+ # forked session's own turns ran with no system prompt at all, since
+ # the parent's instructions were dropped rather than copied over.
+ async def exercise() -> None:
+ adapter = OpencodeServer()
+ process = FakeOpencodeProcess()
+
+ def start_session(harness, options, deployment):
+ return _OpencodeSession(
+ process=process,
+ session_id="parent",
+ cwd=harness.cwd,
+ environment=None,
+ db_path=tmp_path / "opencode.db",
+ instructions=harness.agent.instructions,
+ )
+
+ def fake_fork_session(base_url, session_id, timeout, message_id=None):
+ return {"id": "forked"}
+
+ adapter._start_session = start_session # type: ignore[method-assign]
+ monkeypatch.setattr(
+ "yoke.providers.opencode_server.http.fork_session", fake_fork_session
+ )
+ monkeypatch.setattr(
+ "yoke.providers.opencode_server.http.update_session_permission",
+ lambda *args, **kwargs: {},
+ )
+ harness = Harness(
+ provider="opencode",
+ agent=agent_with_skill(),
+ cwd=tmp_path / "workspace",
+ runtime_root=tmp_path / "runtime",
+ )
+
+ parent = await adapter.start(harness, SessionOptions())
+ forked = await adapter.fork(parent, ForkOptions())
+
+ forked_internal = adapter._sessions[forked.id]
+ assert forked_internal.instructions == "be helpful"
+
+ await adapter.close(forked)
+ await adapter.close(parent)
+
+ asyncio.run(exercise())
+
+
+def test_opencode_start_session_passes_mcp_config_content_env_var(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ captured_env: dict[str, str] = {}
+
+ def fake_start_opencode_server(command, cwd, timeout, env=None):
+ captured_env.update(env or {})
+ return FakeOpencodeProcess()
+
+ monkeypatch.setattr(
+ "yoke.providers.opencode_server.start_opencode_server",
+ fake_start_opencode_server,
+ )
+ monkeypatch.setattr(
+ http, "create_session", lambda *args, **kwargs: {"id": "ses_test"}
+ )
+
+ agent = Agent(
+ instructions="be helpful",
+ options={
+ "mcp_servers": {
+ "docs": {"type": "remote", "url": "https://example.com/mcp"}
+ }
+ },
+ )
+ deployment = deploy_runtime(agent, "opencode", tmp_path)
+ try:
+ harness = Harness(
+ provider="opencode",
+ agent=agent,
+ cwd=tmp_path / "workspace",
+ )
+ adapter = OpencodeServer()
+ adapter._start_session(harness, SessionOptions(), deployment)
+ assert (
+ captured_env["OPENCODE_CONFIG_CONTENT"]
+ == deployment.opencode_config_content
+ )
+ finally:
+ deployment.cleanup()
diff --git a/tests/test_opencode_send.py b/tests/test_opencode_send.py
new file mode 100644
index 0000000..6aef503
--- /dev/null
+++ b/tests/test_opencode_send.py
@@ -0,0 +1,442 @@
+from __future__ import annotations
+
+import asyncio
+import time
+from dataclasses import dataclass
+from pathlib import Path
+
+import pytest
+
+from yoke.errors import YokeError
+from yoke.models import EventKind, RunStatus, Turn
+from yoke.options import OpencodeOptions, ProviderOptions, RunOptions
+from yoke.policies import RequestPolicy
+from yoke.providers.opencode import http
+from yoke.providers.opencode_server import OpencodeServer, _OpencodeSession
+
+
+@dataclass
+class _FakeProcess:
+ base_url: str = "http://127.0.0.1:0"
+
+
+def test_send_emits_provider_session_event_first(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # Regression: Run.provider_session_id scans events in reverse for one
+ # carrying provider_session_id. Without emitting this event, CodeAlmanac
+ # (and any other Yoke consumer) can never populate a transcript
+ # reference for an opencode run — confirmed missing during the
+ # slice-143 codealmanac integration pass.
+ monkeypatch.setattr(
+ http,
+ "post_message",
+ lambda *args, **kwargs: {
+ "info": {"tokens": {"input": 1, "output": 1, "total": 2}},
+ "parts": [{"type": "text", "text": "hello"}],
+ },
+ )
+ server = OpencodeServer()
+ internal = _OpencodeSession(
+ process=_FakeProcess(),
+ session_id="ses_test123",
+ cwd=Path.cwd(),
+ environment=None,
+ db_path=tmp_path / "opencode.db",
+ )
+
+ run = server._send(
+ internal, turn=Turn(prompt="hi"), model="openai/gpt-5", options=RunOptions()
+ )
+
+ assert run.events[0].kind == EventKind.PROVIDER_SESSION
+ assert run.events[0].provider_session_id == "ses_test123"
+ assert run.provider_session_id == "ses_test123"
+ assert run.output == "hello"
+
+
+def test_send_passes_agent_instructions_as_system_on_every_turn(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # OpenCode's POST /session/:id/message has a documented `system` field
+ # (confirmed live, v1.17.15) — Agent.instructions goes through it rather
+ # than being prepended to the prompt text, keeping system and user
+ # content separate.
+ #
+ # Regression: this used to be sent only on the session's first turn, on
+ # the assumption OpenCode retains it session-side like Claude/Codex's
+ # native system-prompt fields. Confirmed live it does not —
+ # LLMRequest.prepare() reads `system` from the current message only, so
+ # a second turn without it ran with no system prompt at all, and every
+ # forked session (which never resends it) got none whatsoever. Sent on
+ # every turn now.
+ sent: list[tuple[str, str | None]] = []
+
+ def fake_post_message(
+ base_url,
+ session_id,
+ cwd,
+ provider_id,
+ model_id,
+ prompt,
+ timeout,
+ *,
+ system=None,
+ ):
+ sent.append((prompt, system))
+ return {"info": {}, "parts": [{"type": "text", "text": "ok"}]}
+
+ monkeypatch.setattr(http, "post_message", fake_post_message)
+ server = OpencodeServer()
+ internal = _OpencodeSession(
+ process=_FakeProcess(),
+ session_id="ses_test123",
+ cwd=Path.cwd(),
+ environment=None,
+ db_path=tmp_path / "opencode.db",
+ instructions="You are a careful maintainer.",
+ )
+
+ server._send(
+ internal,
+ turn=Turn(prompt="turn one"),
+ model="openai/gpt-5",
+ options=RunOptions(),
+ )
+ server._send(
+ internal,
+ turn=Turn(prompt="turn two"),
+ model="openai/gpt-5",
+ options=RunOptions(),
+ )
+
+ assert sent[0] == ("turn one", "You are a careful maintainer.")
+ assert sent[1] == ("turn two", "You are a careful maintainer.")
+
+
+def test_send_resends_instructions_after_a_failed_turn(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ def failing_post_message(*args, **kwargs):
+ raise RuntimeError("connection reset")
+
+ monkeypatch.setattr(http, "post_message", failing_post_message)
+ server = OpencodeServer()
+ internal = _OpencodeSession(
+ process=_FakeProcess(),
+ session_id="ses_test123",
+ cwd=Path.cwd(),
+ environment=None,
+ db_path=tmp_path / "opencode.db",
+ instructions="You are a careful maintainer.",
+ )
+
+ run = server._send(
+ internal,
+ turn=Turn(prompt="turn one"),
+ model="openai/gpt-5",
+ options=RunOptions(),
+ )
+
+ assert run.status == RunStatus.FAILED
+
+ sent: list[tuple[str, str | None]] = []
+
+ def fake_post_message(
+ base_url,
+ session_id,
+ cwd,
+ provider_id,
+ model_id,
+ prompt,
+ timeout,
+ *,
+ system=None,
+ ):
+ sent.append((prompt, system))
+ return {"info": {}, "parts": [{"type": "text", "text": "ok"}]}
+
+ monkeypatch.setattr(http, "post_message", fake_post_message)
+ server._send(
+ internal,
+ turn=Turn(prompt="retry"),
+ model="openai/gpt-5",
+ options=RunOptions(),
+ )
+
+ assert sent == [("retry", "You are a careful maintainer.")]
+
+
+def test_send_resolves_a_pending_permission_via_the_run_level_policy(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ http,
+ "post_message",
+ lambda *args, **kwargs: {"info": {}, "parts": []},
+ )
+ monkeypatch.setattr(
+ http,
+ "list_permissions",
+ lambda base_url, timeout: (
+ {
+ "id": "per_1",
+ "sessionID": "ses_test123",
+ "permission": "bash",
+ "metadata": {"command": "echo hi"},
+ },
+ ),
+ )
+ replies: list[tuple[str, str]] = []
+ monkeypatch.setattr(
+ http,
+ "respond_permission",
+ lambda base_url, permission_id, reply, timeout, **kwargs: replies.append(
+ (permission_id, reply)
+ ),
+ )
+ server = OpencodeServer(poll_interval_seconds=0.01)
+ internal = _OpencodeSession(
+ process=_FakeProcess(),
+ session_id="ses_test123",
+ cwd=Path.cwd(),
+ environment=None,
+ db_path=tmp_path / "opencode.db",
+ )
+
+ run = server._send(
+ internal,
+ turn=Turn(prompt="hi"),
+ model="openai/gpt-5",
+ options=RunOptions(
+ provider=ProviderOptions(
+ opencode=OpencodeOptions(policy=RequestPolicy.allow_all())
+ )
+ ),
+ )
+
+ assert replies == [("per_1", "once")]
+ resolved = [e for e in run.events if e.kind == EventKind.REQUEST_RESOLVED]
+ assert len(resolved) == 1
+ assert resolved[0].response is not None
+ assert resolved[0].response.decision == "allow"
+
+
+def test_send_falls_back_to_the_session_level_policy_set_at_start(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ http,
+ "post_message",
+ lambda *args, **kwargs: {"info": {}, "parts": []},
+ )
+ monkeypatch.setattr(
+ http,
+ "list_permissions",
+ lambda base_url, timeout: (
+ {"id": "per_2", "sessionID": "ses_test123", "permission": "bash"},
+ ),
+ )
+ replies: list[tuple[str, str]] = []
+ monkeypatch.setattr(
+ http,
+ "respond_permission",
+ lambda base_url, permission_id, reply, timeout, **kwargs: replies.append(
+ (permission_id, reply)
+ ),
+ )
+ server = OpencodeServer(poll_interval_seconds=0.01)
+ internal = _OpencodeSession(
+ process=_FakeProcess(),
+ session_id="ses_test123",
+ cwd=Path.cwd(),
+ environment=None,
+ db_path=tmp_path / "opencode.db",
+ provider_options=OpencodeOptions(policy=RequestPolicy.allow_all()),
+ )
+
+ server._send(
+ internal, turn=Turn(prompt="hi"), model="openai/gpt-5", options=RunOptions()
+ )
+
+ assert replies == [("per_2", "once")]
+
+
+def _make_db(path: Path) -> None:
+ import sqlite3
+
+ connection = sqlite3.connect(path)
+ connection.execute(
+ "CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, data TEXT, "
+ "time_created INTEGER)"
+ )
+ connection.execute(
+ "CREATE TABLE part (id TEXT PRIMARY KEY, session_id TEXT, message_id TEXT, "
+ "data TEXT, time_created INTEGER)"
+ )
+ connection.commit()
+ connection.close()
+
+
+def _insert_stuck_tool_part(path: Path, *, session_id: str) -> None:
+ import json
+ import sqlite3
+
+ connection = sqlite3.connect(path)
+ connection.execute(
+ "INSERT OR IGNORE INTO message (id, session_id, data, time_created) "
+ "VALUES (?, ?, ?, ?)",
+ ("m1", session_id, json.dumps({"role": "assistant"}), 1),
+ )
+ stuck_start_ms = int((time.time() - 10_000) * 1000)
+ connection.execute(
+ "INSERT INTO part (id, session_id, message_id, data, time_created) "
+ "VALUES (?, ?, ?, ?, ?)",
+ (
+ "p1",
+ session_id,
+ "m1",
+ json.dumps(
+ {
+ "type": "tool",
+ "tool": "bash",
+ "state": {"status": "running", "time": {"start": stuck_start_ms}},
+ }
+ ),
+ 1,
+ ),
+ )
+ connection.commit()
+ connection.close()
+
+
+def test_send_reacts_to_a_stuck_tool_call_by_terminating_and_returning_failed(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # Regression/coverage gap: only the watchdog itself was tested for
+ # stuck-tool-call detection in isolation. This exercises _send's actual
+ # reaction — stop the watchdog, terminate the process, classify the
+ # failure, and return a FAILED Run — which was previously untested at
+ # the adapter level.
+ db_path = tmp_path / "opencode.db"
+ _make_db(db_path)
+ _insert_stuck_tool_part(db_path, session_id="ses_stuck")
+
+ class _TrackingProcess:
+ def __init__(self) -> None:
+ self.base_url = "http://127.0.0.1:0"
+ self.terminated = False
+
+ def terminate(self) -> None:
+ self.terminated = True
+
+ def hanging_post_message(*args, **kwargs):
+ # Blocks long enough for the watchdog (polling every 0.01s with a
+ # 0.01s stuck threshold against an already-old part row) to detect
+ # the stuck state and cause _send to unwind before this returns.
+ time.sleep(2)
+ return {"info": {}, "parts": []}
+
+ monkeypatch.setattr(http, "post_message", hanging_post_message)
+ server = OpencodeServer(
+ poll_interval_seconds=0.01,
+ stuck_after_seconds=0.01,
+ )
+ process = _TrackingProcess()
+ internal = _OpencodeSession(
+ process=process,
+ session_id="ses_stuck",
+ cwd=Path.cwd(),
+ environment=None,
+ db_path=db_path,
+ )
+
+ run = server._send(
+ internal, turn=Turn(prompt="hi"), model="openai/gpt-5", options=RunOptions()
+ )
+
+ assert run.status == RunStatus.FAILED
+ assert run.failure is not None
+ assert process.terminated is True
+
+
+def test_stream_yields_events_live_and_ends_without_a_final_run(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ http,
+ "post_message",
+ lambda *args, **kwargs: {
+ "info": {"tokens": {"input": 1, "output": 1, "total": 2}},
+ "parts": [{"type": "text", "text": "hello"}],
+ },
+ )
+ server = OpencodeServer()
+ session_id = "ses_stream"
+ server._sessions[session_id] = _OpencodeSession(
+ process=_FakeProcess(),
+ session_id=session_id,
+ cwd=Path.cwd(),
+ environment=None,
+ db_path=tmp_path / "opencode.db",
+ )
+ session = _session_for(server, session_id)
+
+ async def exercise() -> list:
+ events = []
+ async for event in server.stream(
+ session, Turn(prompt="hi"), RunOptions(model="openai/gpt-5")
+ ):
+ events.append(event)
+ return events
+
+ events = asyncio.run(exercise())
+ assert events[0].kind == EventKind.PROVIDER_SESSION
+ assert events[0].provider_session_id == session_id
+
+
+def test_stream_raises_on_failure_instead_of_silently_ending(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ def failing_post_message(*args, **kwargs):
+ raise RuntimeError("opencode request failed")
+
+ monkeypatch.setattr(http, "post_message", failing_post_message)
+ server = OpencodeServer()
+ session_id = "ses_stream_fail"
+ server._sessions[session_id] = _OpencodeSession(
+ process=_FakeProcess(),
+ session_id=session_id,
+ cwd=Path.cwd(),
+ environment=None,
+ db_path=tmp_path / "opencode.db",
+ )
+ session = _session_for(server, session_id)
+
+ async def exercise() -> None:
+ async for _event in server.stream(
+ session, Turn(prompt="hi"), RunOptions(model="openai/gpt-5")
+ ):
+ pass
+
+ with pytest.raises(YokeError):
+ asyncio.run(exercise())
+
+
+def _session_for(server: OpencodeServer, session_id: str):
+ from yoke.models import Session
+
+ return Session(
+ provider=server.provider,
+ surface=server.surface,
+ id=session_id,
+ provider_session_id=session_id,
+ )