Skip to content

feat(opencode): Observe adapter and plugin over a stdio bridge - #26

Merged
SignalLayerLabs merged 1 commit into
SignalLayerLabs:mainfrom
ralyodio:feat/opencode-adapter
Aug 17, 2026
Merged

SignalLayerLabs merged 1 commit into
SignalLayerLabs:mainfrom
ralyodio:feat/opencode-adapter

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Stacked on #25. This branch contains #25's commit because it builds on
marginal.integrations.hookkit. Review #25 first; the diff to read here is the second commit.

Problem

docs/integrations/overview.md lists OpenCode as roadmap work. Its plugin API is quite different from
a hook-per-process engine: plugins are JavaScript modules loaded into the engine's own long-running
process, and a hook blocks a tool call by throwing.

Interface

marginal.integrations.opencode:

  • bridge.py — a newline-delimited JSON service. The plugin owns one bridge child process instead of
    paying interpreter startup per tool call. No socket, no port, no token: the only possible caller is
    the process that spawned it over its own pipes. One bridge governs every session in that OpenCode
    process, because a single process can host several and their tool calls interleave.
  • events.py — strict parsers for the five bridge operations.
  • normalization.py — mapping onto hookkit, and outcome classification from forwarded signals.
  • installer.py / targets.py — reversible file installation into the engine's plugin directory.
  • plugins/marginal-opencode/marginal.js — the plugin itself.

marginal install opencode / marginal uninstall opencode are wired into the CLI.

Behavior

Capability label: Observe. Every flag on AgentCapabilities is false, so the core refuses to run
this adapter in a blocking mode. The plugin contains no throw statement outside comments — in
OpenCode, throwing is blocking — and there is a test asserting that stays true.

If the bridge cannot start, the plugin returns an empty hook set and OpenCode behaves exactly as if it
were not installed. Each bridge request has a timeout (default 5 s) after which the plugin drops it and
continues.

What OpenCode can prove

The shell tool reports an exit code in result metadata, which is a provable success or failure signal.
Most other tools report nothing that separates success from a completed failure, and those outcomes are
recorded as unknown rather than assumed successful. A tool that throws produces no
tool.execute.after call at all, so its proposal is settled as unobservable when the session closes.

This is a weaker evidence surface than an engine reporting success and failure as separate events. It
is enough to detect repeated work and measure governance overhead; it is not enough to justify
enforcement, and none is attempted.

Privacy

Tool output never leaves the OpenCode process. The plugin sends a SHA-256 digest of
{title, output, metadata} plus an allowlist of outcome signals (exit, exit_code, exitCode,
success, status). File content and command output never reach MARGINAL, not even in memory, and
tests assert the plugin reads output?.output only inside the digest call.

Tool arguments do cross into the bridge, because semantic identity and verification detection depend on
them. They are hashed into a semantic key and never persisted. Default profile is LOCAL_FULL
(the ledger stays on the user's machine); MARGINAL_PRIVACY_PROFILE=safe_telemetry switches to keyed
pseudonyms, with a test asserting the raw session identifier disappears. Pseudonymization is not
anonymization.

Validation

Protocol shapes were captured from OpenCode 1.18.18 rather than taken from documentation, which is how
the concurrency detail surfaced: OpenCode starts several tool calls before any completes, and
completions arrive out of order.

768 passed, 1 skipped; ruff and mypy src/marginal clean. 51 new tests cover the bridge protocol
(including malformed lines, oversized lines, unknown operations, unknown sessions, and interleaved
sessions), event parsing, installer reversibility, and the plugin's source-level safety properties.

End-to-end against the real CLI: marginal install opencode, then opencode run was asked to read one
file four times and run a failing shell command. The bridge recorded a session start, four governed
proposals with DUPLICATE_ACTION recommendations on the repeats (SHADOW_OVERRIDE, nothing blocked),
four unknown outcomes, and a session summary with no pending or unmatched actions. Governance latency
was 9.4–10.7 ms per decision. The ledger contained no file names, contents, or command text.

Worth noting: OpenCode's own doom-loop detector independently rejected the fourth read in that session.
The failure mode this project targets is real enough that the engine ships its own guard against it.

Compatibility

Additive. No existing module changes except the two CLI target lists.

Scientific limitations

  • No performance claim. This adds the measurement surface; a paired OFF/ON benchmark is separate work.
  • Latency figures are single-machine observations from one session, not a characterized distribution.
  • The four unknown outcomes in the e2e run are the honest result, and they mean repetition detection
    on read-like tools currently relies on the workspace state hash alone, without completion evidence.
  • Whether tool.execute.after fires only on success is not documented. The adapter does not assume it,
    which is why non-shell outcomes stay unknown rather than being read as successes.
  • Repetition detection assumes a Git work tree; outside one, state is unobservable and every repetition
    control fails open.
  • The plugin's guarantees are asserted at the source level, since the JavaScript is not exercised by the
    Python test suite. The bridge protocol it speaks is covered behaviorally.

@SignalLayerLabs SignalLayerLabs left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks for this — I went through the PR and I like the direction.

The main thing I like is that this is not just “another adapter”. OpenCode has a very different execution model from Codex/Claude, so using the same hookkit + UniversalRuntime path is a good test of whether the integration layer is actually engine-neutral.

A few things I think are done well:

  • keeping the adapter Observe-only
  • refusing to infer success where OpenCode does not expose a reliable outcome signal
  • treating shell exit codes as stronger evidence than generic tool completion
  • fail-open behavior when the bridge is unavailable or times out
  • keeping raw tool output inside the OpenCode process
  • handling interleaved sessions explicitly instead of assuming one linear session per process
  • no performance claim until there is a separate paired benchmark

The stdio bridge also makes sense here. A persistent child process is a better fit than starting Python for every tool call.

I have a couple of things I’d like tightened before merge.

First, the source-level assertion that the JS plugin contains no throw is useful, but I don’t think it is enough as the only safety check for the plugin itself. Since the JS side is part of the Shadow Mode trust boundary, I’d like a small runtime harness that actually loads marginal.js and exercises the failure paths.

At minimum I’d want to see:

  • bridge cannot start → hooks fail open
  • bridge dies after startup → tool execution still proceeds
  • malformed bridge response → fail open
  • request timeout → fail open
  • concurrent / out-of-order tool completions
  • session disposal with pending requests
  • no exception escaping from tool.execute.before

That does not need to become a large JS test suite, but I’d prefer behavioral coverage for the plugin boundary rather than relying only on source inspection.

Second, please be careful with lifecycle/backpressure in the long-running bridge. OpenCode can interleave calls, so I want to make sure request IDs, pending maps, late responses, child termination, closed pipes, and session cleanup cannot leave stale state or grow without bound.

The tests you already added for malformed lines, oversized messages and interleaved sessions are a good base.

One other point: the PR is stacked on #25, so CI is currently inheriting the Claude bootstrap lint failure. I’d prefer to get #25 green and merged first, then rebase this branch so we can evaluate the OpenCode changes independently and actually run the full mypy/pytest/build path in CI.

Architecturally, I think this is the right direction.

The fact that OpenCode already ships a doom-loop detector is also useful context: MARGINAL should not try to win by being another duplicate-action guard. The differentiator needs to be the evidence layer above that — whether an intervention was actually beneficial, intervention regret, and whether a governor has earned enforcement authority.

So my merge criteria here are mostly around proving that the integration boundary is safe and genuinely non-interfering in Shadow Mode.

Once #25 is green/rebased and the JS runtime boundary has a small behavioral test harness, I’d be comfortable moving this toward approval.

Adds an OpenCode integration labeled Observe, built on the shared
`marginal.integrations.hookkit` core.

OpenCode loads plugins into its own long-running process, so the plugin owns one
bridge child process and speaks newline-delimited JSON to it over pipes. There is
no socket, port, or token because the only possible caller is the process that
spawned it. One bridge governs every session in that process.

Tool output never reaches MARGINAL. The plugin sends a SHA-256 digest of the
result plus an allowlist of outcome signals, so file content and command output
stay inside OpenCode.

Outcome observability is honestly weaker than in engines that report success and
failure as separate events: the shell tool reports an exit code, most other tools
prove nothing, and those outcomes are recorded as unknown rather than assumed
successful. A tool that throws produces no completion hook, so its proposal is
settled as unobservable when the session closes.

Installation copies one file into the engine's plugin directory and uninstall
removes it, but only when the file carries MARGINAL's own marker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@SignalLayerLabs
SignalLayerLabs merged commit 549432a into SignalLayerLabs:main Aug 17, 2026
6 checks passed
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