From de320f937e9322f664c94ad821082c2dbd04a1c3 Mon Sep 17 00:00:00 2001
From: d180
Date: Sun, 12 Jul 2026 02:57:50 -0700
Subject: [PATCH 1/9] feat: add OpenCode as a third provider adapter
---
README.md | 18 +-
almanac/concepts/provider-surfaces.md | 14 +
docs/plans/2026-07-11-opencode-provider.md | 72 ++
docs/reference.md | 1 +
src/yoke/adapters.py | 10 +
src/yoke/models.py | 15 +-
src/yoke/providers/opencode/__init__.py | 1 +
src/yoke/providers/opencode/db.py | 33 +
src/yoke/providers/opencode/failures.py | 44 ++
src/yoke/providers/opencode/fields.py | 47 ++
src/yoke/providers/opencode/http.py | 183 +++++
src/yoke/providers/opencode/parts.py | 238 +++++++
src/yoke/providers/opencode/process.py | 125 ++++
src/yoke/providers/opencode/progress.py | 182 +++++
src/yoke/providers/opencode/usage.py | 22 +
src/yoke/providers/opencode_server.py | 747 +++++++++++++++++++++
src/yoke/providers/runtime_deployment.py | 29 +-
src/yoke/surfaces.py | 243 +++++++
tests/test_opencode_parts.py | 165 +++++
tests/test_opencode_progress.py | 292 ++++++++
tests/test_opencode_provider_surface.py | 59 ++
tests/test_opencode_send.py | 102 +++
22 files changed, 2628 insertions(+), 14 deletions(-)
create mode 100644 docs/plans/2026-07-11-opencode-provider.md
create mode 100644 src/yoke/providers/opencode/__init__.py
create mode 100644 src/yoke/providers/opencode/db.py
create mode 100644 src/yoke/providers/opencode/failures.py
create mode 100644 src/yoke/providers/opencode/fields.py
create mode 100644 src/yoke/providers/opencode/http.py
create mode 100644 src/yoke/providers/opencode/parts.py
create mode 100644 src/yoke/providers/opencode/process.py
create mode 100644 src/yoke/providers/opencode/progress.py
create mode 100644 src/yoke/providers/opencode/usage.py
create mode 100644 src/yoke/providers/opencode_server.py
create mode 100644 tests/test_opencode_parts.py
create mode 100644 tests/test_opencode_progress.py
create mode 100644 tests/test_opencode_provider_surface.py
create mode 100644 tests/test_opencode_send.py
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..be649f6 100644
--- a/almanac/concepts/provider-surfaces.md
+++ b/almanac/concepts/provider-surfaces.md
@@ -21,6 +21,12 @@ 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
---
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 +58,11 @@ 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]. Because that live-progress channel is DB-polling rather than SSE, there is also no polling-discoverable "pending permission" signal: session creation always passes an allow-all permission block, and `PERMISSIONS`/`REQUEST_EVENTS` are declared `compiled`/`unsupported` rather than `native`, honestly reflecting that a live interactive approval loop isn't wired [@opencode-plan].
+
+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].
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..8724a85
--- /dev/null
+++ b/docs/plans/2026-07-11-opencode-provider.md
@@ -0,0 +1,72 @@
+# OpenCode Provider Implementation Plan
+
+**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/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/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..7fe1d9d
--- /dev/null
+++ b/src/yoke/providers/opencode/fields.py
@@ -0,0 +1,47 @@
+"""Loose JSON field helpers for OpenCode HTTP/DB payloads."""
+
+from __future__ import annotations
+
+import json
+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
+
+
+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/http.py b/src/yoke/providers/opencode/http.py
new file mode 100644
index 0000000..f90cb04
--- /dev/null
+++ b/src/yoke/providers/opencode/http.py
@@ -0,0 +1,183 @@
+"""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"},
+)
+
+
+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,
+ *,
+ allow_all_permissions: bool = True,
+) -> JsonObject:
+ body: JsonObject = {"title": title}
+ if allow_all_permissions:
+ body["permission"] = list(OPENCODE_ALLOW_ALL_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) -> tuple[JsonObject, ...]:
+ response = httpx.get(f"{base_url}/session", 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 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,
+) -> JsonObject:
+ response = httpx.post(
+ f"{base_url}/session/{session_id}/message",
+ params={"directory": cwd_directory},
+ json={
+ "model": {"providerID": provider_id, "modelID": model_id},
+ "parts": [{"type": "text", "text": prompt}],
+ },
+ timeout=timeout_seconds,
+ )
+ response.raise_for_status()
+ return as_record(response.json())
+
+
+def respond_permission(
+ base_url: str,
+ session_id: str,
+ permission_id: str,
+ response_value: str,
+ timeout_seconds: float,
+ *,
+ remember: bool = False,
+) -> None:
+ response = httpx.post(
+ f"{base_url}/session/{session_id}/permissions/{permission_id}",
+ json={"response": response_value, "remember": remember},
+ 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/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..c146fad
--- /dev/null
+++ b/src/yoke/providers/opencode_server.py
@@ -0,0 +1,747 @@
+"""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 dataclasses import dataclass, field
+from pathlib import Path
+
+from yoke.errors import UnsupportedFeature, YokeError
+from yoke.models import (
+ Event,
+ EventKind,
+ Goal,
+ GoalRun,
+ Harness,
+ Login,
+ Model,
+ Provider,
+ Readiness,
+ Run,
+ RunStatus,
+ Session,
+ SessionHistory,
+ SessionList,
+ SessionMessage,
+ SessionSummary,
+ Surface,
+ Turn,
+ Workflow,
+ WorkflowRun,
+)
+from yoke.options import (
+ ForkOptions,
+ GoalLoopOptions,
+ RunOptions,
+ SessionOptions,
+ WorkflowOptions,
+)
+from yoke.providers.opencode import http
+from yoke.providers.opencode.db import query_readonly_or_empty
+from yoke.providers.opencode.failures import classify_opencode_failure
+from yoke.providers.opencode.fields import JsonObject, as_record, string_field
+from yoke.providers.opencode.parts import final_text_from_parts
+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
+ deployment: RuntimeDeployment | 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)
+ # OpenCode's session/message API has no dedicated system-prompt field
+ # (unlike Claude's system_prompt or Codex app-server's developer
+ # instructions) — Agent.instructions is prepended to the first turn's
+ # prompt text instead. Without this, the model receives only the bare
+ # task prompt and never learns what it's actually supposed to do.
+ instructions: str | None = None
+ instructions_sent: bool = False
+
+
+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] = {}
+
+ 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:
+ session = await self.start(
+ harness,
+ SessionOptions(model=options.model, permissions=options.permissions),
+ )
+ 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:
+ sessions = await asyncio.to_thread(
+ self._list_sessions, harness.cwd, harness.environment, limit
+ )
+ 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,
+ )
+
+ 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")
+ forked = _OpencodeSession(
+ process=internal.process,
+ session_id=forked_id,
+ cwd=internal.cwd,
+ environment=internal.environment,
+ deployment=None,
+ db_path=internal.db_path,
+ )
+ 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._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=options.permissions
+ or harness.permissions
+ or harness.agent.permissions,
+ 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 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)
+ if internal.deployment is not None:
+ await asyncio.to_thread(internal.deployment.cleanup)
+
+ # -- 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()
+ 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,
+ ) -> tuple[SessionSummary, ...]:
+ server = start_opencode_server(
+ self.command,
+ cwd,
+ OPENCODE_RUN_STARTUP_TIMEOUT_SECONDS,
+ env=environment,
+ )
+ try:
+ records = http.list_sessions(
+ server.base_url, OPENCODE_RUN_REQUEST_TIMEOUT_SECONDS
+ )
+ 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,
+ ) -> 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
+ )
+ finally:
+ server.terminate()
+ summary = _session_summary(record, self.provider, self.surface)
+ messages: tuple[SessionMessage, ...] = ()
+ if include_messages:
+ db_path = self._resolve_db_path()
+ rows = query_readonly_or_empty(
+ db_path,
+ "SELECT message.id AS id, message.data AS message_data "
+ "FROM message WHERE message.session_id = ? "
+ "ORDER BY message.time_created",
+ (session_id,),
+ )
+ messages = tuple(
+ SessionMessage(
+ provider=self.provider,
+ surface=self.surface,
+ session_id=session_id,
+ id=row["id"],
+ raw=row["message_data"],
+ )
+ for row in rows
+ )
+ 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)
+ if deployment.opencode_config_dir is not None:
+ environment["OPENCODE_CONFIG_DIR"] = str(deployment.opencode_config_dir)
+ server = start_opencode_server(
+ self.command,
+ harness.cwd,
+ OPENCODE_RUN_STARTUP_TIMEOUT_SECONDS,
+ env=environment,
+ )
+ try:
+ record = http.create_session(
+ server.base_url,
+ str(harness.cwd),
+ harness.agent.description or "yoke run",
+ OPENCODE_RUN_REQUEST_TIMEOUT_SECONDS,
+ allow_all_permissions=_allow_all(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()
+ raise
+ return _OpencodeSession(
+ process=server,
+ session_id=session_id,
+ cwd=harness.cwd,
+ environment=environment or None,
+ deployment=deployment,
+ db_path=self._resolve_db_path(),
+ instructions=harness.agent.instructions,
+ )
+
+ 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
+ if internal.instructions and not internal.instructions_sent:
+ prompt = f"{internal.instructions}\n\n---\n\n{turn.prompt}"
+ internal.instructions_sent = True
+ events: list = []
+ on_event = options.on_event
+
+ def emit(event) -> None:
+ events.append(event)
+ if on_event is not None:
+ on_event(event)
+
+ # 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()
+
+ 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,
+ )
+ 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)
+
+ 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 _allow_all(options: SessionOptions) -> bool:
+ """Whether to pass OpenCode's blanket allow-all permission block.
+
+ Always true today: there is no polling-discoverable "pending permission"
+ signal without SSE (see the opencode provider plan's capability table),
+ so `Approval.ASK` cannot be honored with a live interactive response.
+ This is a named seam so a future SSE-backed approval loop has one call
+ site to change, not a scattered `True` literal.
+ """
+
+ return True
+
+
+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,
+ )
diff --git a/src/yoke/providers/runtime_deployment.py b/src/yoke/providers/runtime_deployment.py
index c97285e..8387259 100644
--- a/src/yoke/providers/runtime_deployment.py
+++ b/src/yoke/providers/runtime_deployment.py
@@ -41,6 +41,7 @@ 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
def cleanup(self) -> None:
"""Remove only this deployment, never authored files."""
@@ -74,6 +75,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 +108,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 +260,18 @@ 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 from a config directory the same shape as
+ # its own `.opencode/` project convention (skills//SKILL.md),
+ # pointed at via OPENCODE_CONFIG_DIR — no files land in the user's real
+ # project, unlike a naive `.opencode/skills/` write into harness.cwd.
+ config_dir = deployment.root / "opencode_config"
+ skills = config_dir / "skills"
+ if not _write_skills(inline_skills(agent), Provider.OPENCODE, skills):
+ return
+ deployment.opencode_config_dir = config_dir
+
+
def _write_skills(
skills: tuple[Skill, ...],
provider: Provider,
diff --git a/src/yoke/surfaces.py b/src/yoke/surfaces.py
index 17c30d4..2d74614 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,48 @@ 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.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.PERMISSIONS): (
+ "https://opencode.ai/docs/server/",
+ ),
}
@@ -766,6 +819,56 @@ 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 a read-only poll "
+ "of OpenCode's own message table for message history."
+ ),
+ (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."
+ ),
+ (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.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): (
+ "MCP servers compile into OPENCODE_CONFIG_CONTENT/OPENCODE_CONFIG_DIR "
+ "config, since OpenCode has no runtime add-server endpoint."
+ ),
+ (Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.PERMISSIONS): (
+ "Session creation always passes an allow-all permission block; there "
+ "is no polling-discoverable pending-permission signal to build a "
+ "live approval loop on without depending on SSE."
+ ),
}
@@ -984,6 +1087,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 +1673,107 @@ 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.COMPILED,
+ "Session creation always passes an allow-all permission block; "
+ "no live per-request approval loop (see REQUEST_EVENTS).",
+ ),
+ Feature.REQUEST_CALLBACKS: (
+ Support.UNSUPPORTED,
+ "See REQUEST_EVENTS.",
+ ),
+ Feature.CODEX_PERMISSIONS: Support.UNSUPPORTED,
+ Feature.CLAUDE_PERMISSIONS: Support.UNSUPPORTED,
+ Feature.FILESYSTEM_AGENT: Support.UNSUPPORTED,
+ 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.UNKNOWN,
+ "GET /agent lists agents but the on-disk format Yoke would "
+ "need to write them in is unconfirmed.",
+ ),
+ 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.UNSUPPORTED,
+ "OpenCode plugins are a separate JS extension mechanism, not "
+ "modeled by Yoke.",
+ ),
+ Feature.HOOKS: Support.UNSUPPORTED,
+ Feature.MCP: (
+ Support.COMPILED,
+ "MCP servers are config-file only; no runtime add-server API.",
+ ),
+ 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.UNSUPPORTED,
+ "POST /session/:id/permissions/:permissionID can answer a "
+ "pending request, but there is no polling-discoverable way to "
+ "learn a permission is pending — OpenCode's docs indicate "
+ "this is only learnable via SSE, which this adapter "
+ "deliberately does not depend on.",
+ ),
+ }
+ ),
}
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_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..60c499c
--- /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"] == "unsupported"
diff --git a/tests/test_opencode_send.py b/tests/test_opencode_send.py
new file mode 100644
index 0000000..73c2ac2
--- /dev/null
+++ b/tests/test_opencode_send.py
@@ -0,0 +1,102 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+
+import pytest
+
+from yoke.models import EventKind, Turn
+from yoke.options import RunOptions
+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,
+ deployment=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_prepends_agent_instructions_on_first_turn_only(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # Regression: OpenCode's session/message API has no dedicated system-
+ # prompt field, unlike Claude/Codex. Without prepending Agent.instructions
+ # to the first turn, the model never receives its actual task
+ # description — confirmed live against a real CodeAlmanac build run,
+ # which produced only a generic clarifying question instead of doing
+ # any work.
+ sent_prompts: list[str] = []
+
+ def fake_post_message(
+ base_url, session_id, cwd, provider_id, model_id, prompt, timeout
+ ):
+ sent_prompts.append(prompt)
+ 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,
+ deployment=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 "You are a careful maintainer." in sent_prompts[0]
+ assert "turn one" in sent_prompts[0]
+ assert sent_prompts[1] == "turn two"
+ assert "You are a careful maintainer." not in sent_prompts[1]
From 8bc219c28d204bb8ac81007bb21cbdcbcb280c11 Mon Sep 17 00:00:00 2001
From: d180
Date: Sun, 12 Jul 2026 14:52:44 -0700
Subject: [PATCH 2/9] fix: implement stream(), fix fork/close deployment
lifecycle, correct MCP matrix
---
src/yoke/providers/_fields.py | 44 +++++
src/yoke/providers/codex_app/fields.py | 50 ++----
src/yoke/providers/opencode/fields.py | 47 ++---
src/yoke/providers/opencode_server.py | 66 ++++++-
src/yoke/surfaces.py | 12 +-
tests/test_opencode_runtime_deployment.py | 209 ++++++++++++++++++++++
tests/test_opencode_send.py | 182 ++++++++++++++++++-
7 files changed, 536 insertions(+), 74 deletions(-)
create mode 100644 src/yoke/providers/_fields.py
create mode 100644 tests/test_opencode_runtime_deployment.py
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/fields.py b/src/yoke/providers/opencode/fields.py
index 7fe1d9d..9fd219b 100644
--- a/src/yoke/providers/opencode/fields.py
+++ b/src/yoke/providers/opencode/fields.py
@@ -3,40 +3,25 @@
from __future__ import annotations
import json
-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
+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:
diff --git a/src/yoke/providers/opencode_server.py b/src/yoke/providers/opencode_server.py
index c146fad..b438ea8 100644
--- a/src/yoke/providers/opencode_server.py
+++ b/src/yoke/providers/opencode_server.py
@@ -11,6 +11,7 @@
import asyncio
import threading
+from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from pathlib import Path
@@ -94,7 +95,6 @@ class _OpencodeSession:
session_id: str
cwd: Path
environment: dict[str, str] | None
- deployment: RuntimeDeployment | 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.
@@ -135,6 +135,12 @@ def __init__(
# 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] = {}
async def check(self, harness: Harness) -> Readiness:
return await asyncio.to_thread(self._check, harness.cwd, harness.environment)
@@ -275,8 +281,14 @@ async def fork(self, session: Session, options: ForkOptions) -> Session:
session_id=forked_id,
cwd=internal.cwd,
environment=internal.environment,
- deployment=None,
db_path=internal.db_path,
+ # A fork already carries the parent conversation's context
+ # server-side, so re-sending instructions on the fork's first
+ # turn would be redundant — mark instructions_sent=True rather
+ # than leaving instructions=None, which would look like they
+ # were never sent at all and isn't the intent here.
+ instructions=internal.instructions,
+ instructions_sent=True,
)
self._retain_process(internal.process)
self._sessions[forked_id] = forked
@@ -326,6 +338,7 @@ async def start(self, harness: Harness, options: SessionOptions) -> Session:
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,
@@ -348,13 +361,54 @@ async def send(self, session: Session, turn: Turn, options: RunOptions) -> Run:
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)
- if internal.deployment is not None:
- await asyncio.to_thread(internal.deployment.cleanup)
# -- synchronous internals, bridged via asyncio.to_thread above --
@@ -374,6 +428,9 @@ def _release_process(self, process: OpencodeServerProcess) -> None:
if count <= 1:
self._process_refs.pop(key, None)
process.terminate()
+ deployment = self._deployments.pop(key, None)
+ if deployment is not None:
+ deployment.cleanup()
return
self._process_refs[key] = count - 1
@@ -570,7 +627,6 @@ def _start_session(
session_id=session_id,
cwd=harness.cwd,
environment=environment or None,
- deployment=deployment,
db_path=self._resolve_db_path(),
instructions=harness.agent.instructions,
)
diff --git a/src/yoke/surfaces.py b/src/yoke/surfaces.py
index 2d74614..58babd4 100644
--- a/src/yoke/surfaces.py
+++ b/src/yoke/surfaces.py
@@ -861,8 +861,9 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities:
"SSE stream was found unreliable for this in a prior live spike."
),
(Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.MCP): (
- "MCP servers compile into OPENCODE_CONFIG_CONTENT/OPENCODE_CONFIG_DIR "
- "config, since OpenCode has no runtime add-server endpoint."
+ "Not yet implemented: OpenCode has no runtime add-server endpoint, "
+ "and this adapter does not compile Yoke MCP config into OpenCode's "
+ "OPENCODE_CONFIG_CONTENT/OPENCODE_CONFIG_DIR config file."
),
(Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.PERMISSIONS): (
"Session creation always passes an allow-all permission block; there "
@@ -1751,8 +1752,11 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities:
),
Feature.HOOKS: Support.UNSUPPORTED,
Feature.MCP: (
- Support.COMPILED,
- "MCP servers are config-file only; no runtime add-server API.",
+ Support.UNSUPPORTED,
+ "OpenCode reads MCP servers from OPENCODE_CONFIG_CONTENT/"
+ "OPENCODE_CONFIG_DIR config, but this adapter does not yet "
+ "compile Yoke MCP config into that file — no runtime "
+ "add-server API exists either.",
),
Feature.GOAL: Support.UNSUPPORTED,
Feature.GOAL_LOOP: Support.UNSUPPORTED,
diff --git a/tests/test_opencode_runtime_deployment.py b/tests/test_opencode_runtime_deployment.py
new file mode 100644
index 0000000..b16b17d
--- /dev/null
+++ b/tests/test_opencode_runtime_deployment.py
@@ -0,0 +1,209 @@
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+
+import pytest
+
+from yoke import Agent, Harness, Skill
+from yoke.options import ForkOptions, SessionOptions
+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_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
+ )
+ 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_marked_as_sent(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ # Regression: fork() previously omitted `instructions`/`instructions_sent`
+ # entirely, so a forked session's first send() would look like
+ # instructions were never sent (instructions_sent defaults to False)
+ # while also never actually sending them, 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
+ )
+ 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"
+ assert forked_internal.instructions_sent is True
+
+ await adapter.close(forked)
+ await adapter.close(parent)
+
+ asyncio.run(exercise())
diff --git a/tests/test_opencode_send.py b/tests/test_opencode_send.py
index 73c2ac2..688d434 100644
--- a/tests/test_opencode_send.py
+++ b/tests/test_opencode_send.py
@@ -1,11 +1,14 @@
from __future__ import annotations
+import asyncio
+import time
from dataclasses import dataclass
from pathlib import Path
import pytest
-from yoke.models import EventKind, Turn
+from yoke.errors import YokeError
+from yoke.models import EventKind, RunStatus, Turn
from yoke.options import RunOptions
from yoke.providers.opencode import http
from yoke.providers.opencode_server import OpencodeServer, _OpencodeSession
@@ -39,7 +42,6 @@ def test_send_emits_provider_session_event_first(
session_id="ses_test123",
cwd=Path.cwd(),
environment=None,
- deployment=None,
db_path=tmp_path / "opencode.db",
)
@@ -78,7 +80,6 @@ def fake_post_message(
session_id="ses_test123",
cwd=Path.cwd(),
environment=None,
- deployment=None,
db_path=tmp_path / "opencode.db",
instructions="You are a careful maintainer.",
)
@@ -100,3 +101,178 @@ def fake_post_message(
assert "turn one" in sent_prompts[0]
assert sent_prompts[1] == "turn two"
assert "You are a careful maintainer." not in sent_prompts[1]
+
+
+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,
+ )
From ac4cd799234f8f0bbc1b8977e6b1bf44fa42ccb4 Mon Sep 17 00:00:00 2001
From: d180
Date: Sun, 12 Jul 2026 15:15:42 -0700
Subject: [PATCH 3/9] feat: compile MCP servers into OpenCode via
OPENCODE_CONFIG_CONTENT
---
src/yoke/providers/opencode_server.py | 2 +
src/yoke/providers/runtime_deployment.py | 14 +++-
src/yoke/surfaces.py | 17 ++---
tests/test_opencode_runtime_deployment.py | 78 +++++++++++++++++++++++
4 files changed, 100 insertions(+), 11 deletions(-)
diff --git a/src/yoke/providers/opencode_server.py b/src/yoke/providers/opencode_server.py
index b438ea8..33771f5 100644
--- a/src/yoke/providers/opencode_server.py
+++ b/src/yoke/providers/opencode_server.py
@@ -602,6 +602,8 @@ def _start_session(
environment = dict(harness.environment)
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
server = start_opencode_server(
self.command,
harness.cwd,
diff --git a/src/yoke/providers/runtime_deployment.py b/src/yoke/providers/runtime_deployment.py
index 8387259..eb85bb3 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
@@ -42,6 +43,7 @@ class RuntimeDeployment:
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."""
@@ -267,9 +269,15 @@ def _write_opencode(agent: Agent, deployment: RuntimeDeployment) -> None:
# project, unlike a naive `.opencode/skills/` write into harness.cwd.
config_dir = deployment.root / "opencode_config"
skills = config_dir / "skills"
- if not _write_skills(inline_skills(agent), Provider.OPENCODE, skills):
- return
- deployment.opencode_config_dir = config_dir
+ if _write_skills(inline_skills(agent), Provider.OPENCODE, skills):
+ 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 _write_skills(
diff --git a/src/yoke/surfaces.py b/src/yoke/surfaces.py
index 58babd4..6571d4a 100644
--- a/src/yoke/surfaces.py
+++ b/src/yoke/surfaces.py
@@ -861,9 +861,10 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities:
"SSE stream was found unreliable for this in a prior live spike."
),
(Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.MCP): (
- "Not yet implemented: OpenCode has no runtime add-server endpoint, "
- "and this adapter does not compile Yoke MCP config into OpenCode's "
- "OPENCODE_CONFIG_CONTENT/OPENCODE_CONFIG_DIR config file."
+ "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.PERMISSIONS): (
"Session creation always passes an allow-all permission block; there "
@@ -1752,11 +1753,11 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities:
),
Feature.HOOKS: Support.UNSUPPORTED,
Feature.MCP: (
- Support.UNSUPPORTED,
- "OpenCode reads MCP servers from OPENCODE_CONFIG_CONTENT/"
- "OPENCODE_CONFIG_DIR config, but this adapter does not yet "
- "compile Yoke MCP config into that file — no runtime "
- "add-server API exists either.",
+ 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,
diff --git a/tests/test_opencode_runtime_deployment.py b/tests/test_opencode_runtime_deployment.py
index b16b17d..cb23ceb 100644
--- a/tests/test_opencode_runtime_deployment.py
+++ b/tests/test_opencode_runtime_deployment.py
@@ -7,6 +7,7 @@
from yoke import Agent, Harness, Skill
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
@@ -46,6 +47,40 @@ def test_opencode_runtime_skips_config_dir_without_skills(tmp_path: Path) -> Non
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_shared_process_keeps_runtime_until_last_release(
tmp_path: Path,
) -> None:
@@ -207,3 +242,46 @@ def fake_fork_session(base_url, session_id, timeout, message_id=None):
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()
From 5c670ece431c546443b097e99d61fcee1ed586a0 Mon Sep 17 00:00:00 2001
From: d180
Date: Sun, 12 Jul 2026 15:24:19 -0700
Subject: [PATCH 4/9] feat: compile Yoke subagents into OpenCode filesystem
agents
---
src/yoke/providers/opencode/agents.py | 68 +++++++++++++++++++++
src/yoke/providers/runtime_deployment.py | 19 ++++--
src/yoke/surfaces.py | 34 +++++++++--
tests/test_opencode_agents.py | 73 +++++++++++++++++++++++
tests/test_opencode_runtime_deployment.py | 39 ++++++++++++
5 files changed, 224 insertions(+), 9 deletions(-)
create mode 100644 src/yoke/providers/opencode/agents.py
create mode 100644 tests/test_opencode_agents.py
diff --git a/src/yoke/providers/opencode/agents.py b/src/yoke/providers/opencode/agents.py
new file mode 100644
index 0000000..5081c1e
--- /dev/null
+++ b/src/yoke/providers/opencode/agents.py
@@ -0,0 +1,68 @@
+"""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 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)}")
+ lines.extend(["---", "", instructions_for(agent)])
+ return "\n".join(lines) + "\n"
+
+
+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/runtime_deployment.py b/src/yoke/providers/runtime_deployment.py
index eb85bb3..4a4f8cb 100644
--- a/src/yoke/providers/runtime_deployment.py
+++ b/src/yoke/providers/runtime_deployment.py
@@ -27,6 +27,7 @@
native_skill_text,
skill_directory_name,
)
+from yoke.providers.opencode.agents import opencode_agent_files
@dataclass(slots=True)
@@ -263,13 +264,21 @@ def _write_claude(agent: Agent, deployment: RuntimeDeployment) -> None:
def _write_opencode(agent: Agent, deployment: RuntimeDeployment) -> None:
- # OpenCode discovers skills from a config directory the same shape as
- # its own `.opencode/` project convention (skills//SKILL.md),
- # pointed at via OPENCODE_CONFIG_DIR — no files land in the user's real
- # project, unlike a naive `.opencode/skills/` write into harness.cwd.
+ # 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"
- if _write_skills(inline_skills(agent), Provider.OPENCODE, skills):
+ wrote_skills = bool(_write_skills(inline_skills(agent), Provider.OPENCODE, skills))
+ wrote_agents = False
+ for file in opencode_agent_files(agent, directory="agents"):
+ path = config_dir / file.path
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(file.text)
+ wrote_agents = True
+ if wrote_skills or wrote_agents:
deployment.opencode_config_dir = config_dir
mcp_servers = option_mapping(agent, "mcp_servers")
if mcp_servers:
diff --git a/src/yoke/surfaces.py b/src/yoke/surfaces.py
index 6571d4a..360d3ee 100644
--- a/src/yoke/surfaces.py
+++ b/src/yoke/surfaces.py
@@ -653,6 +653,13 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities:
"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/",
),
@@ -851,6 +858,16 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities:
"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), written "
+ "under the same OPENCODE_CONFIG_DIR skills use."
+ ),
+ (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."
@@ -1727,16 +1744,25 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities:
),
Feature.CODEX_PERMISSIONS: Support.UNSUPPORTED,
Feature.CLAUDE_PERMISSIONS: Support.UNSUPPORTED,
- Feature.FILESYSTEM_AGENT: 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.UNKNOWN,
- "GET /agent lists agents but the on-disk format Yoke would "
- "need to write them in is unconfirmed.",
+ 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,
diff --git a/tests/test_opencode_agents.py b/tests/test_opencode_agents.py
new file mode 100644
index 0000000..fc62205
--- /dev/null
+++ b/tests/test_opencode_agents.py
@@ -0,0 +1,73 @@
+from __future__ import annotations
+
+from yoke import Agent
+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:
+ 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'
+ "---\n"
+ "\n"
+ "Use primary docs and return links.\n"
+ )
+
+
+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_runtime_deployment.py b/tests/test_opencode_runtime_deployment.py
index cb23ceb..23b814f 100644
--- a/tests/test_opencode_runtime_deployment.py
+++ b/tests/test_opencode_runtime_deployment.py
@@ -81,6 +81,45 @@ def test_opencode_runtime_skips_config_content_without_mcp_servers(
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_shared_process_keeps_runtime_until_last_release(
tmp_path: Path,
) -> None:
From a7879af6326326c289f45f266d5e2cfb8d4043de Mon Sep 17 00:00:00 2001
From: d180
Date: Sun, 12 Jul 2026 16:00:46 -0700
Subject: [PATCH 5/9] feat: wire live OpenCode permission approval via GET
/permission polling
---
src/yoke/options.py | 81 +++++++-
src/yoke/providers/opencode/http.py | 45 ++++-
src/yoke/providers/opencode/permissions.py | 152 +++++++++++++++
src/yoke/providers/opencode_server.py | 79 ++++++--
src/yoke/surfaces.py | 44 +++--
tests/test_opencode_permission_approval.py | 163 ++++++++++++++++
tests/test_opencode_permissions.py | 214 +++++++++++++++++++++
tests/test_opencode_provider_surface.py | 2 +-
tests/test_opencode_send.py | 100 +++++++++-
9 files changed, 843 insertions(+), 37 deletions(-)
create mode 100644 src/yoke/providers/opencode/permissions.py
create mode 100644 tests/test_opencode_permission_approval.py
create mode 100644 tests/test_opencode_permissions.py
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/opencode/http.py b/src/yoke/providers/opencode/http.py
index f90cb04..8017166 100644
--- a/src/yoke/providers/opencode/http.py
+++ b/src/yoke/providers/opencode/http.py
@@ -14,6 +14,9 @@
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, ...]:
@@ -32,11 +35,11 @@ def create_session(
title: str,
timeout_seconds: float,
*,
- allow_all_permissions: bool = True,
+ permission: tuple[JsonObject, ...] = OPENCODE_ALLOW_ALL_PERMISSION,
) -> JsonObject:
body: JsonObject = {"title": title}
- if allow_all_permissions:
- body["permission"] = list(OPENCODE_ALLOW_ALL_PERMISSION)
+ if permission:
+ body["permission"] = list(permission)
response = httpx.post(
f"{base_url}/session",
params={"directory": cwd_directory},
@@ -143,18 +146,44 @@ def post_message(
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,
- session_id: str,
permission_id: str,
- response_value: str,
+ reply: str,
timeout_seconds: float,
*,
- remember: bool = False,
+ 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}/session/{session_id}/permissions/{permission_id}",
- json={"response": response_value, "remember": remember},
+ f"{base_url}/permission/{permission_id}/reply",
+ json=body,
timeout=timeout_seconds,
)
response.raise_for_status()
diff --git a/src/yoke/providers/opencode/permissions.py b/src/yoke/providers/opencode/permissions.py
new file mode 100644
index 0000000..0a3e0f6
--- /dev/null
+++ b/src/yoke/providers/opencode/permissions.py
@@ -0,0 +1,152 @@
+"""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._seen.add(permission_id)
+ 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 surface on the model's turn
+ return
+ 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_server.py b/src/yoke/providers/opencode_server.py
index 33771f5..eaead8c 100644
--- a/src/yoke/providers/opencode_server.py
+++ b/src/yoke/providers/opencode_server.py
@@ -17,6 +17,7 @@
from yoke.errors import UnsupportedFeature, YokeError
from yoke.models import (
+ Approval,
Event,
EventKind,
Goal,
@@ -24,6 +25,7 @@
Harness,
Login,
Model,
+ Permissions,
Provider,
Readiness,
Run,
@@ -41,15 +43,18 @@
from yoke.options import (
ForkOptions,
GoalLoopOptions,
+ OpencodeOptions,
RunOptions,
SessionOptions,
WorkflowOptions,
+ opencode_request_handler,
)
from yoke.providers.opencode import http
from yoke.providers.opencode.db import query_readonly_or_empty
from yoke.providers.opencode.failures import classify_opencode_failure
from yoke.providers.opencode.fields import JsonObject, as_record, string_field
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,
@@ -100,6 +105,9 @@ class _OpencodeSession:
# 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)
# OpenCode's session/message API has no dedicated system-prompt field
# (unlike Claude's system_prompt or Codex app-server's developer
# instructions) — Agent.instructions is prepended to the first turn's
@@ -107,6 +115,10 @@ class _OpencodeSession:
# task prompt and never learns what it's actually supposed to do.
instructions: str | None = None
instructions_sent: bool = False
+ # 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
class OpencodeServer:
@@ -289,6 +301,10 @@ async def fork(self, session: Session, options: ForkOptions) -> Session:
# were never sent at all and isn't the intent here.
instructions=internal.instructions,
instructions_sent=True,
+ # 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
@@ -347,9 +363,7 @@ async def start(self, harness: Harness, options: SessionOptions) -> Session:
provider_session_id=internal.session_id,
agent=harness.agent,
cwd=harness.cwd,
- permissions=options.permissions
- or harness.permissions
- or harness.agent.permissions,
+ permissions=_resolved_permissions(harness, options),
model=options.model or harness.agent.model,
runtime_root=harness.runtime_root,
)
@@ -616,7 +630,9 @@ def _start_session(
str(harness.cwd),
harness.agent.description or "yoke run",
OPENCODE_RUN_REQUEST_TIMEOUT_SECONDS,
- allow_all_permissions=_allow_all(options),
+ permission=_session_permission_block(
+ _resolved_permissions(harness, options)
+ ),
)
session_id = string_field(record, "id")
if session_id is None:
@@ -631,6 +647,7 @@ def _start_session(
environment=environment or None,
db_path=self._resolve_db_path(),
instructions=harness.agent.instructions,
+ provider_options=opencode_options(options),
)
def _resolve_db_path(self) -> Path:
@@ -690,6 +707,22 @@ def emit(event) -> None:
)
watchdog_thread.start()
+ effective_options = (
+ opencode_options_for_run(options) or internal.provider_options
+ )
+ 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:
@@ -730,6 +763,7 @@ def _post() -> None:
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"]
@@ -762,17 +796,38 @@ def _post() -> None:
)
-def _allow_all(options: SessionOptions) -> bool:
- """Whether to pass OpenCode's blanket allow-all permission block.
+def _resolved_permissions(harness: Harness, options: SessionOptions) -> Permissions:
+ return options.permissions or harness.permissions or harness.agent.permissions
- Always true today: there is no polling-discoverable "pending permission"
- signal without SSE (see the opencode provider plan's capability table),
- so `Approval.ASK` cannot be honored with a live interactive response.
- This is a named seam so a future SSE-backed approval loop has one call
- site to change, not a scattered `True` literal.
+
+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).
"""
- return True
+ if permissions.approval is Approval.ASK:
+ return http.OPENCODE_ASK_ALL_PERMISSION
+ return http.OPENCODE_ALLOW_ALL_PERMISSION
+
+
+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, ...]:
diff --git a/src/yoke/surfaces.py b/src/yoke/surfaces.py
index 360d3ee..ab7b2c7 100644
--- a/src/yoke/surfaces.py
+++ b/src/yoke/surfaces.py
@@ -884,9 +884,22 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities:
"endpoint, so servers must be known before the session starts."
),
(Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.PERMISSIONS): (
- "Session creation always passes an allow-all permission block; there "
- "is no polling-discoverable pending-permission signal to build a "
- "live approval loop on without depending on SSE."
+ "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."
+ ),
+ (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."
),
}
@@ -1734,13 +1747,16 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities:
"callback exists in the API but is not wired in this adapter.",
),
Feature.PERMISSIONS: (
- Support.COMPILED,
- "Session creation always passes an allow-all permission block; "
- "no live per-request approval loop (see REQUEST_EVENTS).",
+ 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.UNSUPPORTED,
- "See REQUEST_EVENTS.",
+ 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,
@@ -1798,12 +1814,12 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities:
Feature.NATIVE_WORKFLOW: Support.UNSUPPORTED,
Feature.EXPERIMENTAL_API: Support.UNSUPPORTED,
Feature.REQUEST_EVENTS: (
- Support.UNSUPPORTED,
- "POST /session/:id/permissions/:permissionID can answer a "
- "pending request, but there is no polling-discoverable way to "
- "learn a permission is pending — OpenCode's docs indicate "
- "this is only learnable via SSE, which this adapter "
- "deliberately does not depend on.",
+ 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_permission_approval.py b/tests/test_opencode_permission_approval.py
new file mode 100644
index 0000000..1251289
--- /dev/null
+++ b/tests/test_opencode_permission_approval.py
@@ -0,0 +1,163 @@
+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:
+ assert _session_permission_block(
+ Permissions(approval=Approval.ASK)
+ ) == http.OPENCODE_ASK_ALL_PERMISSION
+
+
+@pytest.mark.parametrize("approval", [Approval.AUTO, Approval.NEVER])
+def test_session_permission_block_allows_all_for_auto_and_never(
+ approval: Approval,
+) -> None:
+ assert (
+ _session_permission_block(Permissions(approval=approval))
+ == http.OPENCODE_ALLOW_ALL_PERMISSION
+ )
+
+
+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)
+
+ harness = Harness(
+ provider="opencode",
+ agent=Agent(instructions="x", permissions=Permissions(approval=Approval.ASK)),
+ 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
+ )
+ 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())
diff --git a/tests/test_opencode_permissions.py b/tests/test_opencode_permissions.py
new file mode 100644
index 0000000..c016a09
--- /dev/null
+++ b/tests/test_opencode_permissions.py
@@ -0,0 +1,214 @@
+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_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_provider_surface.py b/tests/test_opencode_provider_surface.py
index 60c499c..83b0c72 100644
--- a/tests/test_opencode_provider_surface.py
+++ b/tests/test_opencode_provider_surface.py
@@ -56,4 +56,4 @@ def test_opencode_surface_report_is_app_server_channel_and_runnable() -> None:
features = {row.feature: row.support for row in report.features}
assert features["session"] == "native"
assert features["streaming"] == "emulated"
- assert features["request_events"] == "unsupported"
+ assert features["request_events"] == "compiled"
diff --git a/tests/test_opencode_send.py b/tests/test_opencode_send.py
index 688d434..47a62cc 100644
--- a/tests/test_opencode_send.py
+++ b/tests/test_opencode_send.py
@@ -9,7 +9,8 @@
from yoke.errors import YokeError
from yoke.models import EventKind, RunStatus, Turn
-from yoke.options import RunOptions
+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
@@ -103,6 +104,103 @@ def fake_post_message(
assert "You are a careful maintainer." not in sent_prompts[1]
+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
From a18e2c36263e1ca963943051dc98a4d4bf7afc43 Mon Sep 17 00:00:00 2001
From: d180
Date: Sun, 12 Jul 2026 16:33:03 -0700
Subject: [PATCH 6/9] feat: build OpenCode plugin pass-through and generated
tool-call hooks
---
src/yoke/providers/opencode/hooks.py | 172 ++++++++++++++++++++++
src/yoke/providers/opencode_server.py | 108 +++++++++++++-
src/yoke/providers/runtime_deployment.py | 29 +++-
src/yoke/surfaces.py | 38 ++++-
tests/test_opencode_hook_bridge_wiring.py | 166 +++++++++++++++++++++
tests/test_opencode_hooks.py | 98 ++++++++++++
tests/test_opencode_runtime_deployment.py | 36 +++++
7 files changed, 640 insertions(+), 7 deletions(-)
create mode 100644 src/yoke/providers/opencode/hooks.py
create mode 100644 tests/test_opencode_hook_bridge_wiring.py
create mode 100644 tests/test_opencode_hooks.py
diff --git a/src/yoke/providers/opencode/hooks.py b/src/yoke/providers/opencode/hooks.py
new file mode 100644
index 0000000..d76f615
--- /dev/null
+++ b/src/yoke/providers/opencode/hooks.py
@@ -0,0 +1,172 @@
+"""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,
+ }),
+ });
+ 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 = {}
+ 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()
+ 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_server.py b/src/yoke/providers/opencode_server.py
index eaead8c..27ea191 100644
--- a/src/yoke/providers/opencode_server.py
+++ b/src/yoke/providers/opencode_server.py
@@ -11,7 +11,7 @@
import asyncio
import threading
-from collections.abc import AsyncIterator
+from collections.abc import AsyncIterator, Callable
from dataclasses import dataclass, field
from pathlib import Path
@@ -28,6 +28,7 @@
Permissions,
Provider,
Readiness,
+ Response,
Run,
RunStatus,
Session,
@@ -53,6 +54,11 @@
from yoke.providers.opencode.db import query_readonly_or_empty
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 (
@@ -119,6 +125,11 @@ class _OpencodeSession:
# 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. None between turns and while idle.
+ current_emit: Callable[[Event], None] | None = None
class OpencodeServer:
@@ -153,14 +164,30 @@ def __init__(
# 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, permissions=options.permissions),
+ 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)
@@ -445,6 +472,9 @@ def _release_process(self, process: OpencodeServerProcess) -> None:
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
@@ -614,10 +644,16 @@ def _start_session(
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
server = start_opencode_server(
self.command,
harness.cwd,
@@ -639,7 +675,11 @@ def _start_session(
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,
@@ -650,6 +690,47 @@ def _start_session(
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)
+ handler = (
+ opencode_request_handler(internal.provider_options)
+ 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
@@ -678,6 +759,29 @@ def emit(event) -> None:
if on_event is not None:
on_event(event)
+ # Lets a live tool.execute.before hook call (OpencodeHookBridge,
+ # providers/opencode/hooks.py) emit its resolved event into *this*
+ # turn's stream — 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 `emit` closure at construction time.
+ internal.current_emit = emit
+ try:
+ return self._send_turn(
+ internal, prompt, provider_id, model_id, options, emit, events
+ )
+ finally:
+ internal.current_emit = None
+
+ def _send_turn(
+ self,
+ internal: _OpencodeSession,
+ prompt: str,
+ provider_id: str,
+ model_id: str,
+ options: RunOptions,
+ emit: Callable[[Event], None],
+ events: list,
+ ) -> 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.
diff --git a/src/yoke/providers/runtime_deployment.py b/src/yoke/providers/runtime_deployment.py
index 4a4f8cb..f6b59ed 100644
--- a/src/yoke/providers/runtime_deployment.py
+++ b/src/yoke/providers/runtime_deployment.py
@@ -278,7 +278,13 @@ def _write_opencode(agent: Agent, deployment: RuntimeDeployment) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(file.text)
wrote_agents = True
- if wrote_skills or wrote_agents:
+ 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:
@@ -289,6 +295,27 @@ def _write_opencode(agent: Agent, deployment: RuntimeDeployment) -> None:
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 ab7b2c7..0da374d 100644
--- a/src/yoke/surfaces.py
+++ b/src/yoke/surfaces.py
@@ -670,6 +670,12 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities:
"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/",
),
@@ -883,6 +889,19 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities:
"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): (
"Permissions.approval=ASK passes an ask-all session permission "
"block instead of allow-all; OpencodePermissionWatchdog polls "
@@ -1789,11 +1808,22 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities:
"user's real project.",
),
Feature.PLUGINS: (
- Support.UNSUPPORTED,
- "OpenCode plugins are a separate JS extension mechanism, not "
- "modeled by Yoke.",
+ 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. Deployed only when a handler/policy is "
+ "configured at session start; RunOptions.provider.opencode "
+ "set later per-turn does not retroactively enable it.",
),
- Feature.HOOKS: Support.UNSUPPORTED,
Feature.MCP: (
Support.COMPILED,
"agent.options['mcp_servers'] compiles into an "
diff --git a/tests/test_opencode_hook_bridge_wiring.py b/tests/test_opencode_hook_bridge_wiring.py
new file mode 100644
index 0000000..e270e9a
--- /dev/null
+++ b/tests/test_opencode_hook_bridge_wiring.py
@@ -0,0 +1,166 @@
+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_sessions_own_policy_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.deny_all("no")),
+ current_emit=events.append,
+ )
+ 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_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
+ ):
+ 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"
diff --git a/tests/test_opencode_hooks.py b/tests/test_opencode_hooks.py
new file mode 100644
index 0000000..9efc0bd
--- /dev/null
+++ b/tests/test_opencode_hooks.py
@@ -0,0 +1,98 @@
+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()
diff --git a/tests/test_opencode_runtime_deployment.py b/tests/test_opencode_runtime_deployment.py
index 23b814f..c813076 100644
--- a/tests/test_opencode_runtime_deployment.py
+++ b/tests/test_opencode_runtime_deployment.py
@@ -47,6 +47,42 @@ def test_opencode_runtime_skips_config_dir_without_skills(tmp_path: Path) -> Non
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:
From 1848cfe99d2fdc0d25b8695a14c17eb51fba28a0 Mon Sep 17 00:00:00 2001
From: d180
Date: Sun, 12 Jul 2026 19:24:16 -0700
Subject: [PATCH 7/9] fix: address independent review findings in plugins/hooks
work
---
almanac/concepts/provider-surfaces.md | 26 +++++-
docs/plans/2026-07-11-opencode-provider.md | 9 +++
src/yoke/providers/opencode/hooks.py | 41 +++++++---
src/yoke/providers/opencode_server.py | 69 +++++++++++-----
src/yoke/providers/runtime_deployment.py | 11 ++-
src/yoke/surfaces.py | 9 ++-
tests/test_opencode_hook_bridge_wiring.py | 94 +++++++++++++++++++++-
tests/test_opencode_hooks.py | 24 ++++++
tests/test_opencode_runtime_deployment.py | 16 ++++
9 files changed, 264 insertions(+), 35 deletions(-)
diff --git a/almanac/concepts/provider-surfaces.md b/almanac/concepts/provider-surfaces.md
index be649f6..39b8deb 100644
--- a/almanac/concepts/provider-surfaces.md
+++ b/almanac/concepts/provider-surfaces.md
@@ -27,6 +27,18 @@ sources:
- 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].
@@ -63,6 +75,18 @@ This is why the decision note calls provider surfaces first-class. A generic pro
`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]. Because that live-progress channel is DB-polling rather than SSE, there is also no polling-discoverable "pending permission" signal: session creation always passes an allow-all permission block, and `PERMISSIONS`/`REQUEST_EVENTS` are declared `compiled`/`unsupported` rather than `native`, honestly reflecting that a live interactive approval loop isn't wired [@opencode-plan].
+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].
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].
+
+### 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].
+
+### 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].
+
+### 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
index 8724a85..294f517 100644
--- a/docs/plans/2026-07-11-opencode-provider.md
+++ b/docs/plans/2026-07-11-opencode-provider.md
@@ -1,5 +1,14 @@
# 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.
diff --git a/src/yoke/providers/opencode/hooks.py b/src/yoke/providers/opencode/hooks.py
index d76f615..9ee0fd8 100644
--- a/src/yoke/providers/opencode/hooks.py
+++ b/src/yoke/providers/opencode/hooks.py
@@ -47,6 +47,12 @@
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) {
@@ -87,15 +93,32 @@ def do_POST(self) -> None: # noqa: N802 - stdlib handler name
payload = as_record(json.loads(self.rfile.read(length) or b"{}"))
except ValueError:
payload = {}
- 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()
+ # 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)))
diff --git a/src/yoke/providers/opencode_server.py b/src/yoke/providers/opencode_server.py
index 27ea191..506f495 100644
--- a/src/yoke/providers/opencode_server.py
+++ b/src/yoke/providers/opencode_server.py
@@ -128,8 +128,11 @@ class _OpencodeSession:
# 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. None between turns and while idle.
+ # 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:
@@ -654,12 +657,23 @@ def _start_session(
environment["OPENCODE_CONFIG_CONTENT"] = deployment.opencode_config_content
if hook_bridge is not None:
environment["YOKE_HOOK_BRIDGE_URL"] = hook_bridge.base_url
- server = start_opencode_server(
- self.command,
- harness.cwd,
- OPENCODE_RUN_STARTUP_TIMEOUT_SECONDS,
- env=environment,
- )
+ 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,
@@ -717,11 +731,12 @@ def _maybe_deploy_hook_bridge(
def _resolve_hook_request(self, session_id: str, payload: JsonObject) -> Response:
internal = self._sessions.get(session_id)
- handler = (
- opencode_request_handler(internal.provider_options)
- if internal is not None
- else None
- )
+ # 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(
@@ -759,18 +774,36 @@ def emit(event) -> None:
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 — 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 `emit` closure at construction time.
+ # 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, provider_id, model_id, options, emit, events
+ internal,
+ prompt,
+ provider_id,
+ model_id,
+ options,
+ emit,
+ events,
+ effective_options,
)
finally:
internal.current_emit = None
+ internal.current_request_handler = None
def _send_turn(
self,
@@ -781,6 +814,7 @@ def _send_turn(
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
@@ -811,9 +845,6 @@ def _send_turn(
)
watchdog_thread.start()
- effective_options = (
- opencode_options_for_run(options) or internal.provider_options
- )
permission_watchdog = OpencodePermissionWatchdog(
base_url=internal.process.base_url,
session_id=internal.session_id,
diff --git a/src/yoke/providers/runtime_deployment.py b/src/yoke/providers/runtime_deployment.py
index f6b59ed..a914f40 100644
--- a/src/yoke/providers/runtime_deployment.py
+++ b/src/yoke/providers/runtime_deployment.py
@@ -273,7 +273,16 @@ def _write_opencode(agent: Agent, deployment: RuntimeDeployment) -> None:
skills = config_dir / "skills"
wrote_skills = bool(_write_skills(inline_skills(agent), Provider.OPENCODE, skills))
wrote_agents = False
- for file in opencode_agent_files(agent, directory="agents"):
+ 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)
diff --git a/src/yoke/surfaces.py b/src/yoke/surfaces.py
index 0da374d..79cb405 100644
--- a/src/yoke/surfaces.py
+++ b/src/yoke/surfaces.py
@@ -1820,9 +1820,12 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities:
"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. Deployed only when a handler/policy is "
- "configured at session start; RunOptions.provider.opencode "
- "set later per-turn does not retroactively enable it.",
+ "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,
diff --git a/tests/test_opencode_hook_bridge_wiring.py b/tests/test_opencode_hook_bridge_wiring.py
index e270e9a..66f5528 100644
--- a/tests/test_opencode_hook_bridge_wiring.py
+++ b/tests/test_opencode_hook_bridge_wiring.py
@@ -60,7 +60,7 @@ def test_maybe_deploy_hook_bridge_writes_the_plugin_and_starts_a_server(
deployment.cleanup()
-def test_resolve_hook_request_routes_to_the_sessions_own_policy_and_emits(
+def test_resolve_hook_request_routes_to_the_turns_active_handler_and_emits(
tmp_path: Path,
) -> None:
adapter = OpencodeServer()
@@ -71,8 +71,9 @@ def test_resolve_hook_request_routes_to_the_sessions_own_policy_and_emits(
cwd=Path.cwd(),
environment=None,
db_path=tmp_path / "opencode.db",
- provider_options=OpencodeOptions(policy=RequestPolicy.deny_all("no")),
+ provider_options=OpencodeOptions(policy=RequestPolicy.allow_all()),
current_emit=events.append,
+ current_request_handler=RequestPolicy.deny_all("no"),
)
adapter._sessions["ses_test123"] = internal
@@ -87,6 +88,36 @@ def test_resolve_hook_request_routes_to_the_sessions_own_policy_and_emits(
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()
@@ -164,3 +195,62 @@ def fake_post_message(
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
index 9efc0bd..91b5647 100644
--- a/tests/test_opencode_hooks.py
+++ b/tests/test_opencode_hooks.py
@@ -96,3 +96,27 @@ def fake_resolve(session_id, payload):
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_runtime_deployment.py b/tests/test_opencode_runtime_deployment.py
index c813076..0315f83 100644
--- a/tests/test_opencode_runtime_deployment.py
+++ b/tests/test_opencode_runtime_deployment.py
@@ -6,6 +6,7 @@
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
@@ -156,6 +157,21 @@ def test_opencode_runtime_sets_config_dir_for_subagents_without_skills(
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:
From a6b0707cb06b80ce9d8ebd66c3731f95d6a084c6 Mon Sep 17 00:00:00 2001
From: d180
Date: Mon, 13 Jul 2026 17:22:30 -0700
Subject: [PATCH 8/9] fix: address remaining PR review findings
---
almanac/concepts/provider-surfaces.md | 8 +-
src/yoke/providers/opencode/agents.py | 36 ++++-
src/yoke/providers/opencode/http.py | 69 ++++++++-
src/yoke/providers/opencode/permissions.py | 8 +-
src/yoke/providers/opencode_server.py | 144 +++++++++++++++----
src/yoke/surfaces.py | 33 +++--
tests/test_opencode_agents.py | 36 +++++
tests/test_opencode_history.py | 157 +++++++++++++++++++++
tests/test_opencode_hook_bridge_wiring.py | 2 +-
tests/test_opencode_permission_approval.py | 117 +++++++++++++--
tests/test_opencode_permissions.py | 36 +++++
tests/test_opencode_runtime_deployment.py | 8 ++
tests/test_opencode_send.py | 94 ++++++++++--
13 files changed, 676 insertions(+), 72 deletions(-)
create mode 100644 tests/test_opencode_history.py
diff --git a/almanac/concepts/provider-surfaces.md b/almanac/concepts/provider-surfaces.md
index 39b8deb..fab3f7a 100644
--- a/almanac/concepts/provider-surfaces.md
+++ b/almanac/concepts/provider-surfaces.md
@@ -75,18 +75,22 @@ This is why the decision note calls provider surfaces first-class. A generic pro
`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].
+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].
+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/src/yoke/providers/opencode/agents.py b/src/yoke/providers/opencode/agents.py
index 5081c1e..4d5682d 100644
--- a/src/yoke/providers/opencode/agents.py
+++ b/src/yoke/providers/opencode/agents.py
@@ -16,7 +16,7 @@
from dataclasses import dataclass
from pathlib import Path
-from yoke.models import Agent
+from yoke.models import Access, Agent
from yoke.providers.codex_agents import description_for, instructions_for, slug
@@ -57,10 +57,44 @@ def opencode_agent_markdown(name: str, agent: Agent) -> str:
]
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
diff --git a/src/yoke/providers/opencode/http.py b/src/yoke/providers/opencode/http.py
index 8017166..06d5895 100644
--- a/src/yoke/providers/opencode/http.py
+++ b/src/yoke/providers/opencode/http.py
@@ -50,8 +50,36 @@ def create_session(
return as_record(response.json())
-def list_sessions(base_url: str, timeout_seconds: float) -> tuple[JsonObject, ...]:
- response = httpx.get(f"{base_url}/session", timeout=timeout_seconds)
+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):
@@ -80,6 +108,30 @@ def rename_session(
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()
@@ -132,14 +184,19 @@ def post_message(
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={
- "model": {"providerID": provider_id, "modelID": model_id},
- "parts": [{"type": "text", "text": prompt}],
- },
+ json=body,
timeout=timeout_seconds,
)
response.raise_for_status()
diff --git a/src/yoke/providers/opencode/permissions.py b/src/yoke/providers/opencode/permissions.py
index 0a3e0f6..5d06108 100644
--- a/src/yoke/providers/opencode/permissions.py
+++ b/src/yoke/providers/opencode/permissions.py
@@ -77,7 +77,6 @@ def _poll_once(self) -> None:
continue
if string_field(record, "sessionID") != self.session_id:
continue
- self._seen.add(permission_id)
self._resolve(record, permission_id)
def _resolve(self, record: JsonObject, permission_id: str) -> None:
@@ -92,8 +91,13 @@ def _resolve(self, record: JsonObject, permission_id: str) -> None:
self.timeout_seconds,
message=response.message,
)
- except Exception: # noqa: BLE001 - reply failures surface on the model's turn
+ 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}
diff --git a/src/yoke/providers/opencode_server.py b/src/yoke/providers/opencode_server.py
index 506f495..d011648 100644
--- a/src/yoke/providers/opencode_server.py
+++ b/src/yoke/providers/opencode_server.py
@@ -17,6 +17,7 @@
from yoke.errors import UnsupportedFeature, YokeError
from yoke.models import (
+ Access,
Approval,
Event,
EventKind,
@@ -51,7 +52,6 @@
opencode_request_handler,
)
from yoke.providers.opencode import http
-from yoke.providers.opencode.db import query_readonly_or_empty
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 (
@@ -268,8 +268,14 @@ async def list_sessions(
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
+ self._list_sessions, harness.cwd, harness.environment, limit, cwd
)
return SessionList(
provider=self.provider, surface=self.surface, sessions=sessions
@@ -290,6 +296,8 @@ async def read_session(
harness.environment,
session_id,
include_messages,
+ limit,
+ offset,
)
async def rename(self, session: Session, title: str) -> SessionSummary:
@@ -318,6 +326,21 @@ async def fork(self, session: Session, options: ForkOptions) -> Session:
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,
@@ -574,6 +597,7 @@ def _list_sessions(
cwd: Path,
environment: dict[str, str],
limit: int | None,
+ directory: str | Path | None,
) -> tuple[SessionSummary, ...]:
server = start_opencode_server(
self.command,
@@ -582,8 +606,14 @@ def _list_sessions(
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
+ server.base_url,
+ OPENCODE_RUN_REQUEST_TIMEOUT_SECONDS,
+ directory=str(directory) if directory is not None else None,
)
finally:
server.terminate()
@@ -599,6 +629,8 @@ def _read_session(
environment: dict[str, str],
session_id: str,
include_messages: bool,
+ limit: int | None,
+ offset: int,
) -> SessionHistory:
server = start_opencode_server(
self.command,
@@ -610,29 +642,29 @@ def _read_session(
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)
- messages: tuple[SessionMessage, ...] = ()
- if include_messages:
- db_path = self._resolve_db_path()
- rows = query_readonly_or_empty(
- db_path,
- "SELECT message.id AS id, message.data AS message_data "
- "FROM message WHERE message.session_id = ? "
- "ORDER BY message.time_created",
- (session_id,),
- )
- messages = tuple(
- SessionMessage(
- provider=self.provider,
- surface=self.surface,
- session_id=session_id,
- id=row["id"],
- raw=row["message_data"],
- )
- for row in rows
- )
return SessionHistory(
provider=self.provider,
surface=self.surface,
@@ -763,9 +795,15 @@ def _send(
) -> Run:
provider_id, model_id = split_opencode_model(model)
prompt = turn.prompt
- if internal.instructions and not internal.instructions_sent:
- prompt = f"{internal.instructions}\n\n---\n\n{turn.prompt}"
- internal.instructions_sent = True
+ # 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. Only sent once, on the session's first
+ # turn; `instructions_sent` is set after the request actually
+ # succeeds (see _send_turn/_post below), not here — setting it
+ # upfront meant a failed first send silently lost the instructions
+ # on retry, since the flag was already True.
+ system = internal.instructions if not internal.instructions_sent else None
events: list = []
on_event = options.on_event
@@ -794,6 +832,7 @@ def emit(event) -> None:
return self._send_turn(
internal,
prompt,
+ system,
provider_id,
model_id,
options,
@@ -809,6 +848,7 @@ def _send_turn(
self,
internal: _OpencodeSession,
prompt: str,
+ system: str | None,
provider_id: str,
model_id: str,
options: RunOptions,
@@ -870,9 +910,15 @@ def _post() -> None:
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
+ return
+ # Only recorded once the request actually succeeded — a failed
+ # first send must still see instructions on the next retry.
+ if system is not None:
+ internal.instructions_sent = True
sender_thread = threading.Thread(target=_post, daemon=True)
sender_thread.start()
@@ -944,11 +990,34 @@ def _session_permission_block(permissions: Permissions) -> tuple[JsonObject, ...
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.
"""
- if permissions.approval is Approval.ASK:
- return http.OPENCODE_ASK_ALL_PERMISSION
- return http.OPENCODE_ALLOW_ALL_PERMISSION
+ 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]:
@@ -993,3 +1062,20 @@ def _session_summary(
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/surfaces.py b/src/yoke/surfaces.py
index 79cb405..9c4b364 100644
--- a/src/yoke/surfaces.py
+++ b/src/yoke/surfaces.py
@@ -836,8 +836,10 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities:
"Harness.sessions() calls GET /session."
),
(Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.SESSION_READ): (
- "Harness.read_session() calls GET /session/:id, plus a read-only poll "
- "of OpenCode's own message table for message history."
+ "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."
@@ -846,7 +848,10 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities:
"Session.compact() calls POST /session/:id/summarize."
),
(Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.FORK): (
- "Session.fork() calls POST /session/:id/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."
@@ -866,8 +871,13 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities:
),
(Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.FILESYSTEM_AGENT): (
"Direct Yoke subagents render as agents/.md markdown with "
- "YAML frontmatter (description, mode: subagent, model), written "
- "under the same OPENCODE_CONFIG_DIR skills use."
+ "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 "
@@ -903,10 +913,15 @@ def unknown_surface(provider: Provider, surface: str) -> Capabilities:
"both change what actually runs, not just what's reported."
),
(Provider.OPENCODE, Surface.OPENCODE_SERVER, Feature.PERMISSIONS): (
- "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."
+ "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 "
diff --git a/tests/test_opencode_agents.py b/tests/test_opencode_agents.py
index fc62205..49842d3 100644
--- a/tests/test_opencode_agents.py
+++ b/tests/test_opencode_agents.py
@@ -1,6 +1,7 @@
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
@@ -27,6 +28,10 @@ def test_opencode_agent_files_compile_direct_subagents() -> None:
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.",
@@ -40,12 +45,43 @@ def test_opencode_agent_markdown_renders_frontmatter_and_body() -> None:
'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.")
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
index 66f5528..3be927b 100644
--- a/tests/test_opencode_hook_bridge_wiring.py
+++ b/tests/test_opencode_hook_bridge_wiring.py
@@ -142,7 +142,7 @@ def test_send_intercepts_a_tool_call_via_a_live_hook_bridge(
captured_reply = {}
def fake_post_message(
- base_url, session_id, cwd, provider_id, model_id, prompt, timeout
+ base_url, session_id, cwd, provider_id, model_id, prompt, timeout, **kwargs
):
reply = httpx.post(
f"{base_url}/tool-hook",
diff --git a/tests/test_opencode_permission_approval.py b/tests/test_opencode_permission_approval.py
index 1251289..4d364f8 100644
--- a/tests/test_opencode_permission_approval.py
+++ b/tests/test_opencode_permission_approval.py
@@ -22,19 +22,51 @@
def test_session_permission_block_asks_for_approval_permissions() -> None:
- assert _session_permission_block(
- Permissions(approval=Approval.ASK)
- ) == http.OPENCODE_ASK_ALL_PERMISSION
+ # 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_auto_and_never(
+def test_session_permission_block_allows_all_for_full_access_and_network(
approval: Approval,
) -> None:
- assert (
- _session_permission_block(Permissions(approval=approval))
- == http.OPENCODE_ALLOW_ALL_PERMISSION
+ 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:
@@ -100,9 +132,10 @@ def fake_create_session(base_url, cwd_directory, title, timeout, *, permission):
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(approval=Approval.ASK)),
+ agent=Agent(instructions="x", permissions=permissions),
cwd=tmp_path / "workspace",
)
adapter = OpencodeServer()
@@ -143,6 +176,10 @@ def fake_fork_session(base_url, session_id, timeout, message_id=None):
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"),
@@ -161,3 +198,67 @@ def fake_fork_session(base_url, session_id, timeout, message_id=None):
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
index c016a09..0cadaf6 100644
--- a/tests/test_opencode_permissions.py
+++ b/tests/test_opencode_permissions.py
@@ -197,6 +197,42 @@ def test_watchdog_run_stops_promptly_and_does_a_final_poll(
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:
diff --git a/tests/test_opencode_runtime_deployment.py b/tests/test_opencode_runtime_deployment.py
index 0315f83..1bcfc7e 100644
--- a/tests/test_opencode_runtime_deployment.py
+++ b/tests/test_opencode_runtime_deployment.py
@@ -260,6 +260,10 @@ def fake_fork_session(base_url, session_id, timeout, message_id=None):
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",
@@ -315,6 +319,10 @@ def fake_fork_session(base_url, session_id, timeout, message_id=None):
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(),
diff --git a/tests/test_opencode_send.py b/tests/test_opencode_send.py
index 47a62cc..ac9026a 100644
--- a/tests/test_opencode_send.py
+++ b/tests/test_opencode_send.py
@@ -56,22 +56,30 @@ def test_send_emits_provider_session_event_first(
assert run.output == "hello"
-def test_send_prepends_agent_instructions_on_first_turn_only(
+def test_send_passes_agent_instructions_as_system_on_first_turn_only(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
- # Regression: OpenCode's session/message API has no dedicated system-
- # prompt field, unlike Claude/Codex. Without prepending Agent.instructions
- # to the first turn, the model never receives its actual task
- # description — confirmed live against a real CodeAlmanac build run,
- # which produced only a generic clarifying question instead of doing
- # any work.
- sent_prompts: list[str] = []
+ # 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. Sent only on the session's first turn, matching the
+ # once-per-session semantics Claude/Codex use for their own native
+ # system-prompt fields.
+ sent: list[tuple[str, str | None]] = []
def fake_post_message(
- base_url, session_id, cwd, provider_id, model_id, prompt, timeout
+ base_url,
+ session_id,
+ cwd,
+ provider_id,
+ model_id,
+ prompt,
+ timeout,
+ *,
+ system=None,
):
- sent_prompts.append(prompt)
+ sent.append((prompt, system))
return {"info": {}, "parts": [{"type": "text", "text": "ok"}]}
monkeypatch.setattr(http, "post_message", fake_post_message)
@@ -98,10 +106,68 @@ def fake_post_message(
options=RunOptions(),
)
- assert "You are a careful maintainer." in sent_prompts[0]
- assert "turn one" in sent_prompts[0]
- assert sent_prompts[1] == "turn two"
- assert "You are a careful maintainer." not in sent_prompts[1]
+ assert sent[0] == ("turn one", "You are a careful maintainer.")
+ assert sent[1] == ("turn two", None)
+ assert internal.instructions_sent is True
+
+
+def test_send_keeps_instructions_unsent_when_the_first_post_fails(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # Regression: instructions_sent used to become True before the HTTP
+ # request succeeded, so a failed first send lost the instructions on
+ # retry — the model's very first real turn would run with no system
+ # prompt at all.
+ 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
+ assert internal.instructions_sent is False
+
+ 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(
From 13382fdaeb29a7bc69eb0183f38a672659431351 Mon Sep 17 00:00:00 2001
From: d180
Date: Mon, 13 Jul 2026 17:56:47 -0700
Subject: [PATCH 9/9] fix: send OpenCode system instructions on every turn, not
just the first
---
src/yoke/providers/opencode_server.py | 42 ++++++++++-------------
tests/test_opencode_runtime_deployment.py | 11 +++---
tests/test_opencode_send.py | 24 ++++++-------
3 files changed, 35 insertions(+), 42 deletions(-)
diff --git a/src/yoke/providers/opencode_server.py b/src/yoke/providers/opencode_server.py
index d011648..a04440d 100644
--- a/src/yoke/providers/opencode_server.py
+++ b/src/yoke/providers/opencode_server.py
@@ -114,13 +114,10 @@ class _OpencodeSession:
# 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)
- # OpenCode's session/message API has no dedicated system-prompt field
- # (unlike Claude's system_prompt or Codex app-server's developer
- # instructions) — Agent.instructions is prepended to the first turn's
- # prompt text instead. Without this, the model receives only the bare
- # task prompt and never learns what it's actually supposed to do.
+ # 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
- instructions_sent: bool = False
# 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.
@@ -347,13 +344,11 @@ async def fork(self, session: Session, options: ForkOptions) -> Session:
cwd=internal.cwd,
environment=internal.environment,
db_path=internal.db_path,
- # A fork already carries the parent conversation's context
- # server-side, so re-sending instructions on the fork's first
- # turn would be redundant — mark instructions_sent=True rather
- # than leaving instructions=None, which would look like they
- # were never sent at all and isn't the intent here.
+ # `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,
- instructions_sent=True,
# 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.
@@ -798,12 +793,18 @@ def _send(
# 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. Only sent once, on the session's first
- # turn; `instructions_sent` is set after the request actually
- # succeeds (see _send_turn/_post below), not here — setting it
- # upfront meant a failed first send silently lost the instructions
- # on retry, since the flag was already True.
- system = internal.instructions if not internal.instructions_sent else None
+ # 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
@@ -914,11 +915,6 @@ def _post() -> None:
)
except Exception as error: # noqa: BLE001 - surfaced below
message_result["error"] = error
- return
- # Only recorded once the request actually succeeded — a failed
- # first send must still see instructions on the next retry.
- if system is not None:
- internal.instructions_sent = True
sender_thread = threading.Thread(target=_post, daemon=True)
sender_thread.start()
diff --git a/tests/test_opencode_runtime_deployment.py b/tests/test_opencode_runtime_deployment.py
index 1bcfc7e..a8f55be 100644
--- a/tests/test_opencode_runtime_deployment.py
+++ b/tests/test_opencode_runtime_deployment.py
@@ -289,15 +289,13 @@ def fake_fork_session(base_url, session_id, timeout, message_id=None):
asyncio.run(exercise())
-def test_opencode_fork_carries_over_parent_instructions_marked_as_sent(
+def test_opencode_fork_carries_over_parent_instructions(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
- # Regression: fork() previously omitted `instructions`/`instructions_sent`
- # entirely, so a forked session's first send() would look like
- # instructions were never sent (instructions_sent defaults to False)
- # while also never actually sending them, since the parent's
- # instructions were dropped rather than copied over.
+ # 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()
@@ -335,7 +333,6 @@ def fake_fork_session(base_url, session_id, timeout, message_id=None):
forked_internal = adapter._sessions[forked.id]
assert forked_internal.instructions == "be helpful"
- assert forked_internal.instructions_sent is True
await adapter.close(forked)
await adapter.close(parent)
diff --git a/tests/test_opencode_send.py b/tests/test_opencode_send.py
index ac9026a..6aef503 100644
--- a/tests/test_opencode_send.py
+++ b/tests/test_opencode_send.py
@@ -56,16 +56,22 @@ def test_send_emits_provider_session_event_first(
assert run.output == "hello"
-def test_send_passes_agent_instructions_as_system_on_first_turn_only(
+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. Sent only on the session's first turn, matching the
- # once-per-session semantics Claude/Codex use for their own native
- # system-prompt fields.
+ # 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(
@@ -107,18 +113,13 @@ def fake_post_message(
)
assert sent[0] == ("turn one", "You are a careful maintainer.")
- assert sent[1] == ("turn two", None)
- assert internal.instructions_sent is True
+ assert sent[1] == ("turn two", "You are a careful maintainer.")
-def test_send_keeps_instructions_unsent_when_the_first_post_fails(
+def test_send_resends_instructions_after_a_failed_turn(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
- # Regression: instructions_sent used to become True before the HTTP
- # request succeeded, so a failed first send lost the instructions on
- # retry — the model's very first real turn would run with no system
- # prompt at all.
def failing_post_message(*args, **kwargs):
raise RuntimeError("connection reset")
@@ -141,7 +142,6 @@ def failing_post_message(*args, **kwargs):
)
assert run.status == RunStatus.FAILED
- assert internal.instructions_sent is False
sent: list[tuple[str, str | None]] = []