Skip to content

feat(integrations): OpenCode plugin — execd-side receipt emission for native tool calls#766

Open
ojongerius wants to merge 8 commits into
mainfrom
claude/sharp-faraday-ebzqfb
Open

feat(integrations): OpenCode plugin — execd-side receipt emission for native tool calls#766
ojongerius wants to merge 8 commits into
mainfrom
claude/sharp-faraday-ebzqfb

Conversation

@ojongerius

@ojongerius ojongerius commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the OpenCode analog of the Claude Code hook integration: a TypeScript plugin (@agent-receipts/opencode-plugin) that emits one daemon-signed Agent Receipt per native OpenCode tool call (bash/edit/write/webfetch/…). This is the Tier B / native-tool channel; the MCP channel (Tier A) is docs-only via the existing mcp-proxy.

Repo structure

The package lives at integrations/opencode-plugin/ under a new integrations/ grouping for per-agent-runtime adapters — following the repo's existing family-grouping convention (sdk/). The Claude Code hook/ is the other member of this family and migrates in a follow-up (#767); until then it stays at the root.

Two-tier design

  • Tier A — MCP channel (docs only). Point OpenCode's opencode.json MCP config at mcp-proxy — the adversary-resistant, out-of-process placement. Reuses a shipped component; no new code.
  • Tier B — native channel (the build). The plugin hooks tool.execute.before/tool.execute.after and forwards each call to agent-receipts-daemon via the TS SDK DaemonEmitter.

Trust boundary (load-bearing)

The plugin runs inside the OpenCode process, so it is an emitter only — it never instantiates a signer, signs, or holds a key (ADR-0010, daemon-sole-writer). This is the execd-side, honest-operator placement: maximum coverage of native tool calls, but not adversary-resistant (a compromised OpenCode can omit/misreport). Code, labels, and docs do not claim otherwise. The mcp-proxy placement (Tier A) is the adversary-resistant one for MCP.

What's in the package

  • recorder.ts — framework-agnostic core (tool calls → DaemonEmitter emissions), one emitter per sessionID.
  • actions.ts — OpenCode tool name → AR taxonomy action type (bashsystem.command.execute, edit/write/patchfilesystem.file.modify, webfetchdata.api.read), overridable.
  • config.ts — env + programmatic config, tool allow/deny.
  • plugin.ts — OpenCode adapter (Plugin export, hooks, session.deleted/dispose lifecycle); typechecks against @opencode-ai/plugin@1.16.2.
  • Tests: unit (mapping, filtering, intent/params bridging, per-session emitters, strict/default posture) + a round-trip test driving the real DaemonEmitter against a fake daemon socket and asserting wire-frame shape.

Supporting SDK change

sdk/ts: adds optional EmitEvent.actionType → daemon frame action_type. The daemon already accepted this field (and prefers it over the synthetic . fallback); this exposes it through the TS emitter so the plugin's action mapping makes risk-based controls effective. Additive, backwards-compatible, not a receipt-format change. Tests + CHANGELOG included.

Action & chain mapping

  • Action mapping binds params by hash (the daemon hashes inputparameters_hash, RFC 8785); the daemon re-derives risk_level from the type, so an emitter cannot downgrade risk. write maps to filesystem.file.modify (medium), not create, since OpenCode's write can overwrite an existing file — under-reporting a destructive overwrite as low-risk would be wrong.
  • Chain mapping — each OpenCode sessionID maps to its own emitter (→ the daemon root chain). Per-agent sub-chains with delegation backlinks (feat(agent-id): agent_id forwarding, Delegation struct, multi-chain daemon routing #753) are a filed follow-up: the tool.execute hook context exposes only { tool, sessionID, callID }, not a named-agent identity, so the sub-chain keying cannot be derived without guessing. Session.parentID is the hook the follow-up will use.

Failure posture (ADR-0025)

Default catch-and-warn — a tool call is never aborted because the daemon is unreachable; the drop is logged loudly (⇒ possible chain gaps, surfaced by agent-receipts verify — honest-operator-grade, not a completeness guarantee). strict re-throws emit failures.

Docs & CI

  • New OpenCode docs section (overview, plugin installation + end-to-end agent-receipts verify walkthrough, Tier A mcp-proxy config) with the site nav updated in this PR. Daemon-connecting TS snippets carry the no-run snippet directive.
  • New .github/workflows/opencode-plugin.yml — path-filtered on integrations/opencode-plugin/** and sdk/ts/** (the plugin links the in-tree SDK via file:), builds the SDK first, then runs typecheck / lint / build / test.

Acceptance criteria

  • One daemon-signed receipt per native tool call; round-trip test asserts the chain/frame shape.
  • Daemon-down default posture: tool calls still run, loud warning; strict surfaces the failure (ADR-0025).
  • Docs build, nav updated, snippets pass the CI gate.
  • Round-trip test against a local/in-memory daemon socket (reuses the SDK test-harness pattern).
  • Merge blocker: end-to-end tested against a real OpenCode instance — receipts visible in daemon store, agent-receipts verify passes.
  • Subagent sub-chain with delegation linkage — deferred with a filed follow-up (feat(agent-id): agent_id forwarding, Delegation struct, multi-chain daemon routing #753) per the API gap above (single per-session chain ships).

Non-goals / follow-ups

  • No in-process signing or key handling; no adversary-resistance claim for the plugin path; no changes to OpenCode; no spec/receipt-format change.
  • Move hook/integrations/ to complete the grouping — tracked in Move hook/ into integrations/ to complete the integrations/ grouping #767.
  • Publishing follow-up: no release/publish workflow yet, and the file:../../sdk/ts dev dependency should be swapped for a versioned @agnt-rcpt/sdk-ts at publish time.

Verification

  • opencode-plugin: typecheck clean, biome clean, 31 tests pass (verified from a clean --frozen-lockfile install at integrations/opencode-plugin/).
  • sdk/ts: 433 tests pass (incl. the 2 new actionType tests).
  • Site builds; OpenCode nav + 3 pages render; MDX snippet gate passes.
  • Branch is merged up to date with main (incl. the ADR-0026 / 0.12.0 graduation that landed during development; orthogonal to this change).

The daemon already accepts an `action_type` frame field and prefers it over
the synthetic `<channel>.<tool>` fallback (which defaults risk to medium),
but the TS DaemonEmitter had no way to send it. Add an optional
`EmitEvent.actionType` that maps to the frame `action_type`, so emitters
that already know the taxonomic action type — such as the OpenCode plugin —
can make risk-based controls effective. The daemon still re-derives
risk_level from the type, so an emitter cannot downgrade risk.

Additive and backwards-compatible: omitting the field preserves prior
behaviour. Not a receipt-format change (the daemon already emitted these
receipts). Adds round-trip tests asserting the field is present when set
and absent when omitted.
…ssion

New `@agent-receipts/opencode-plugin` package — the OpenCode analog of the
Claude Code hook integration, covering the native-tool channel (Tier B).
Hooks `tool.execute.before`/`tool.execute.after` and emits one
daemon-signed Agent Receipt per native tool call (bash/edit/write/webfetch/…)
via the TS SDK DaemonEmitter.

Trust boundary (load-bearing): the plugin runs inside the OpenCode process,
so it is an emitter only — it never instantiates a signer, signs, or holds
a key (ADR-0010 daemon-sole-writer). This is the execd-side, honest-operator
placement: max coverage, not adversary-resistant. The mcp-proxy MCP
placement (Tier A) is the adversary-resistant one. Code, labels, and docs
do not claim otherwise.

- Action mapping: OpenCode tool names → AR taxonomy, forwarded as
  action_type; the daemon re-derives risk so mislabelling cannot downgrade
  it. Unmapped tools fall back to the daemon's <channel>.<tool>.
- Chain mapping: one DaemonEmitter per sessionID so receipts carry the
  session id. Per-agent sub-chains with delegation (#753) are a filed
  follow-up — the tool.execute hook context exposes only
  { tool, sessionID, callID }, not a named-agent identity, so the keying
  cannot be derived without guessing.
- Failure posture (ADR-0025): default catch-and-warn never aborts a tool
  call; strict re-throws. Best-effort ⇒ possible chain gaps ⇒
  honest-operator-grade, surfaced by `agent-receipts verify`.
- Config: env vars and createAgentReceiptsPlugin(config) — socket, channel,
  strict, tool allow/deny, action-type overrides.

Tests: unit tests (mapping, filtering, intent/params bridging, per-session
emitters, strict/default posture) plus a round-trip test driving the real
DaemonEmitter against a fake daemon socket and asserting wire-frame shape.

Docs: OpenCode section (overview, plugin installation + end-to-end verify
walkthrough, Tier A mcp-proxy config) with site nav updated. Daemon-
connecting TS snippets carry the no-run snippet directive.
The release commit bumped pyproject to 0.12.0a1 but left uv.lock recording
0.11.1. The pre-push uv hook regenerates the lockfile and surfaces this
drift; commit the one-line sync so the working tree stays clean. Incidental
to this branch — no dependency or functional change.
- pendingArgs leak: a `tool.execute.before` with no matching `after` (an
  aborted/cancelled tool call) previously leaked its entry for the life of
  the process — the map was keyed by callID, so closeSession could not
  reclaim it. Store the sessionID alongside each pending intent and evict a
  session's orphaned intents in closeSession. Adds a regression test.

- Session teardown: stop closing the per-session emitter on `session.idle`.
  OpenCode fires session.idle after every turn (not at session end), so
  closing there churned sockets and could tear a connection down mid-emit
  (a spurious transport failure — a loud warning by default, or a thrown
  error from the after-hook under strict). Close only on `session.deleted`
  and plugin `dispose`.

- Drop the dead `emitter?: "daemon"` config field: it was declared but never
  read by resolveConfig, so setting it was a silent no-op. The daemon-only /
  no-WAL decision stays documented in the README and docs.

Docs prose updated to match the session.deleted teardown.
…ebzqfb

# Conflicts:
#	sdk/py/uv.lock
#	sdk/ts/CHANGELOG.md
@ojongerius ojongerius changed the title feat(sdk-ts): expose optional actionType on DaemonEmitter EmitEvent feat(integrations): OpenCode plugin — execd-side receipt emission for native tool calls Jun 10, 2026
OpenCode's `write` tool creates a file OR overwrites an existing one. Mapping
it to `filesystem.file.create` (low) under-reported the destructive-overwrite
case as low risk, so risk-based parameter disclosure ("high") would not fire.
Map to the more conservative `filesystem.file.modify` (medium) since we cannot
tell create from overwrite without inspecting the filesystem. Updates the
mapping tests and the docs action-mapping table.
…flow

Adds `.github/workflows/opencode-plugin.yml`, mirroring the sdk-ts workflow
conventions. Path-filtered on `opencode-plugin/**` and `sdk/ts/**` — the
latter because the plugin links the in-tree SDK via `file:../sdk/ts`, so a
same-PR sdk/ts change that breaks the plugin must fail this check. The job
builds sdk/ts dist first, then runs the plugin's typecheck, lint, build, and
test.
Introduce an `integrations/` grouping for per-agent-runtime adapters and
move the new plugin under it (`integrations/opencode-plugin/`), following
the repo's existing family-grouping convention (`sdk/<lang>`). The Claude
Code `hook/` is the other member of this family and will migrate in a
follow-up; until then it stays at the root.

- `file:` dependency depth: `file:../sdk/ts` → `file:../../sdk/ts`; lockfile
  regenerated to match.
- CI workflow path filters and working-directory updated to
  `integrations/opencode-plugin/**`.
- Relative doc links (`../hook/`, `../mcp-proxy/`, `../LICENSE`,
  `file:` notes) re-pointed one level up.
- Root AGENTS.md monorepo layout documents the new `integrations/` dir.

Verified from a clean --frozen-lockfile install at the new path: typecheck,
lint, build, and 31 tests pass.
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.

1 participant