Skip to content

feat: add OpenCode as a third provider adapter#1

Open
d180 wants to merge 9 commits into
AlmanacCode:mainfrom
d180:feat/opencode-provider
Open

feat: add OpenCode as a third provider adapter#1
d180 wants to merge 9 commits into
AlmanacCode:mainfrom
d180:feat/opencode-provider

Conversation

@d180

@d180 d180 commented Jul 12, 2026

Copy link
Copy Markdown

Summary

Adds OpenCode as a third ProviderAdapter, alongside Claude and Codex, via
opencode serve's local HTTP server (Surface.OPENCODE_SERVER). OpenCode has
no 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 serve process lifecycle itself — the same
sync-core-bridged-via-asyncio.to_thread pattern already used by
CodexAppServer.

Design notes

  • All session history lives in one shared SQLite DB
    (~/.local/share/opencode/opencode.db), not per-session files, so
    read_session/transcript inspection is a read-only DB query.
  • OpenCode's SSE stream was unreliable for live progress narration in an
    earlier spike, so progress/events are driven by polling the
    message/part tables instead (OpencodeProgressWatchdog), including
    detection 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.
  • OpenCode has no dedicated system-prompt field, so Agent.instructions is
    prepended to the first turn's prompt text.
  • Forked sessions share their parent's underlying process; reference-counted
    via _retain_process/_release_process, mirroring CodexAppServer. The
    same process-scoped refcounting now also covers the generated hook bridge
    server (below).
  • Live permission approval (OpencodePermissionWatchdog): the original
    assumption — that a pending permission is only learnable via SSE — was
    wrong. GET /permission is a real, non-deprecated, polling-discoverable
    endpoint, confirmed live against a real opencode serve process.
    Permissions.approval=ASK now passes an ask-all session permission block
    instead of always allow-all; pending permissions are resolved through
    ProviderOptions.opencode.request_handler/.policy, the same
    RequestPolicy/Response contract Claude and Codex app-server already
    use for their own request callbacks.
  • Filesystem agents: direct Yoke subagents compile to agents/<name>.md
    markdown files with YAML frontmatter (mode: subagent), written into the
    same OPENCODE_CONFIG_DIR-pointed directory already used for skills.
    Confirmed live via GET /agent — OpenCode's own discovery lists the
    generated agent alongside its built-ins — and via actual model-driven
    delegation to it.
  • MCP servers: agent.options["mcp_servers"] compiles to
    {"mcp": {...}} JSON passed through OPENCODE_CONFIG_CONTENT, OpenCode's
    highest-precedence config source (no runtime add-server endpoint exists).
    Confirmed live against a real MCP server (tool discovery + a real tool
    call round trip).
  • Plugins and hooks: two different things. 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.before plugin relays every
    tool call to a new local HTTP bridge (OpencodeHookBridge, process-scoped
    like the deployment/refcounting above) that resolves it through the same
    request_handler/.policy contract. Confirmed live that both argument
    mutation (Response.updated_input) and denial actually change what the
    model'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))

  • Native: session list/read/rename/compact, fork, interrupt, models,
    login (api_key), skills, inline subagents, permissions
  • Emulated: streaming, run-event callbacks (DB-poll watchdog, not native
    SSE)
  • Compiled: MCP, filesystem agents, declared subagents, plugins, hooks,
    request events/callbacks
  • Unsupported: structured output, session tag, goal/goal-loop, native
    workflow, collab agent tools/collaboration mode
  • Unknown: session resume (unconfirmed against the real API)

Full matrix in src/yoke/surfaces.py; narrative writeup in
almanac/concepts/provider-surfaces.md.

Testing

  • uv run ruff check . — clean
  • uv run pytest — 581 passed
  • uv build — succeeds
  • Live-tested against a real opencode CLI and real git repos through
    CodeAlmanac's init/garden/ingest/sync commands, including
    multi-turn sessions, forked sessions, and a real stuck-tool-call recovery.
  • Every feature added after the initial adapter (permission approval, MCP,
    filesystem agents, plugins, hooks) was independently live-verified against
    a real opencode serve process and a real MCP server, not just mocked
    unit tests — including a live cross-repo test through CodeAlmanac's own
    YokeHarnessAdapter.

@divitsheth divitsheth left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/yoke/providers/opencode_server.py Outdated
summary = _session_summary(record, self.provider, self.surface)
messages: tuple[SessionMessage, ...] = ()
if include_messages:
db_path = self._resolve_db_path()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/yoke/providers/opencode_server.py Outdated
) -> Run:
provider_id, model_id = split_opencode_model(model)
prompt = turn.prompt
if internal.instructions and not internal.instructions_sent:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@d180

d180 commented Jul 14, 2026

Copy link
Copy Markdown
Author

@divitsheth I did the changes as mentioned in the comments.

@d180 d180 requested a review from divitsheth July 14, 2026 01:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants