feat: add OpenCode as a third provider adapter#1
Conversation
There was a problem hiding this comment.
Thank you so much for this. I rechecked the current head against the Yoke adapter contracts and OpenCode v1.17.13/current server API. I am requesting changes on a small set of behavioral parity and safety gaps: permission enforcement, stored-session semantics, system instructions, and retrying failed permission replies. The provider-specific internals can differ; the goal here is that capabilities marked supported keep the same Yoke-facing contract.
| str(harness.cwd), | ||
| harness.agent.description or "yoke run", | ||
| OPENCODE_RUN_REQUEST_TIMEOUT_SECONDS, | ||
| permission=_session_permission_block( |
There was a problem hiding this comment.
Could we preserve the full Yoke permission contract here? This currently maps only approval. For example, Permissions(access=READ, network=False, approval=NEVER) becomes OpenCode * = allow, so the session reports read-only/no-network while execution is unrestricted. OpenCode accepts per-session rules, so please translate the access and network restrictions where representable, and reject combinations that cannot be represented safely. I would consider this blocking because it changes the requested safety boundary.
| summary = _session_summary(record, self.provider, self.surface) | ||
| messages: tuple[SessionMessage, ...] = () | ||
| if include_messages: | ||
| db_path = self._resolve_db_path() |
There was a problem hiding this comment.
Could we route stored history through the documented GET /session/:id/message API instead of the private SQLite schema? That endpoint supports limit, and offset can be applied locally. This also fixes the currently ignored limit/offset arguments. Separately, list_sessions() silently ignores cursor, cwd, and include_worktrees; the existing adapter pattern is to honor an option or raise UnsupportedFeature (Claude does this for cursor), rather than accept a no-op. The HTTP path also avoids relying on _resolve_db_path(), which can be wrong when OpenCode uses XDG_DATA_HOME, OPENCODE_DB, or a channel-specific database name. DB polling may still be needed for live progress, but stored history has a public API.
| ) -> Run: | ||
| provider_id, model_id = split_opencode_model(model) | ||
| prompt = turn.prompt | ||
| if internal.instructions and not internal.instructions_sent: |
There was a problem hiding this comment.
Could we pass Agent.instructions through OpenCode’s documented system field on POST /session/:id/message instead of changing the first user prompt? The current OpenCode schema, including v1.17.13, supports system. There is also a retry bug here: instructions_sent becomes true before the HTTP request succeeds, so a failed first send causes the retry to lose the instructions. Using the native field keeps system and user content separate and avoids that state problem.
There was a problem hiding this comment.
Could we send system=internal.instructions on every turn instead of only the first? In the current OpenCode implementation, LLMRequest.prepare() reads system from the current user message, so it is not a persistent session-level prompt. With instructions_sent=True, later turns and forks run without the agent instructions. Sending the system field on every message also removes the need for instructions_sent state.
| continue | ||
| if string_field(record, "sessionID") != self.session_id: | ||
| continue | ||
| self._seen.add(permission_id) |
There was a problem hiding this comment.
Could we mark this permission as seen only after respond_permission() succeeds, or remove it from _seen when the reply fails? Right now a transient reply failure is swallowed after the ID is recorded, so the watchdog never retries it and the in-flight message can remain blocked until its outer timeout.
|
@divitsheth I did the changes as mentioned in the comments. |
Summary
Adds OpenCode as a third
ProviderAdapter, alongside Claude and Codex, viaopencode serve's local HTTP server (Surface.OPENCODE_SERVER). OpenCode hasno official Python SDK with the coverage Yoke needs (no fork/rename/
single-session-read/auth-set/permission-response, and it doesn't manage the
server process), so this adapter talks to the documented HTTP API directly
and manages the
opencode serveprocess lifecycle itself — the samesync-core-bridged-via-
asyncio.to_threadpattern already used byCodexAppServer.Design notes
(
~/.local/share/opencode/opencode.db), not per-session files, soread_session/transcript inspection is a read-only DB query.earlier spike, so progress/events are driven by polling the
message/parttables instead (OpencodeProgressWatchdog), includingdetection of OpenCode's known upstream stuck-tool-call issue
(Glob tool execute has no timeout — hangs indefinitely when ripgrep scan is slow anomalyco/opencode#33541) via a 240s threshold.
Agent.instructionsisprepended to the first turn's prompt text.
via
_retain_process/_release_process, mirroringCodexAppServer. Thesame process-scoped refcounting now also covers the generated hook bridge
server (below).
OpencodePermissionWatchdog): the originalassumption — that a pending permission is only learnable via SSE — was
wrong.
GET /permissionis a real, non-deprecated, polling-discoverableendpoint, confirmed live against a real
opencode serveprocess.Permissions.approval=ASKnow passes an ask-all session permission blockinstead of always allow-all; pending permissions are resolved through
ProviderOptions.opencode.request_handler/.policy, the sameRequestPolicy/Responsecontract Claude and Codex app-server alreadyuse for their own request callbacks.
agents/<name>.mdmarkdown files with YAML frontmatter (
mode: subagent), written into thesame
OPENCODE_CONFIG_DIR-pointed directory already used for skills.Confirmed live via
GET /agent— OpenCode's own discovery lists thegenerated agent alongside its built-ins — and via actual model-driven
delegation to it.
agent.options["mcp_servers"]compiles to{"mcp": {...}}JSON passed throughOPENCODE_CONFIG_CONTENT, OpenCode'shighest-precedence config source (no runtime add-server endpoint exists).
Confirmed live against a real MCP server (tool discovery + a real tool
call round trip).
agent.options["opencode_plugins"](name → raw JS source) is pure pass-through, written as
plugin/<name>.js— confirmed live that OpenCode actually loads and runs a caller-supplied
plugin. Hooks are generated: a
tool.execute.beforeplugin relays everytool call to a new local HTTP bridge (
OpencodeHookBridge, process-scopedlike the deployment/refcounting above) that resolves it through the same
request_handler/.policycontract. Confirmed live that both argumentmutation (
Response.updated_input) and denial actually change what themodel's tool call does, not just what gets reported. Opt-in only — no
configured handler means no plugin file and no bridge server.
Feature support levels (
(Provider.OPENCODE, Surface.OPENCODE_SERVER))login (api_key), skills, inline subagents, permissions
SSE)
request events/callbacks
workflow, collab agent tools/collaboration mode
Full matrix in
src/yoke/surfaces.py; narrative writeup inalmanac/concepts/provider-surfaces.md.Testing
uv run ruff check .— cleanuv run pytest— 581 passeduv build— succeedsopencodeCLI and real git repos throughCodeAlmanac's
init/garden/ingest/synccommands, includingmulti-turn sessions, forked sessions, and a real stuck-tool-call recovery.
filesystem agents, plugins, hooks) was independently live-verified against
a real
opencode serveprocess and a real MCP server, not just mockedunit tests — including a live cross-repo test through CodeAlmanac's own
YokeHarnessAdapter.