Repository navigation
feat(opencode): Observe adapter and plugin over a stdio bridge - #26
Conversation
SignalLayerLabs
left a comment
There was a problem hiding this comment.
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>
24bc8c4 to
61d30cf
Compare
Problem
docs/integrations/overview.mdlists OpenCode as roadmap work. Its plugin API is quite different froma 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 ofpaying 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 ontohookkit, 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 opencodeare wired into the CLI.Behavior
Capability label: Observe. Every flag on
AgentCapabilitiesis false, so the core refuses to runthis adapter in a blocking mode. The plugin contains no
throwstatement outside comments — inOpenCode, 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
unknownrather than assumed successful. A tool that throws produces notool.execute.aftercall 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, andtests assert the plugin reads
output?.outputonly 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_telemetryswitches to keyedpseudonyms, 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;ruffandmypy src/marginalclean. 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, thenopencode runwas asked to read onefile four times and run a failing shell command. The bridge recorded a session start, four governed
proposals with
DUPLICATE_ACTIONrecommendations on the repeats (SHADOW_OVERRIDE, nothing blocked),four
unknownoutcomes, and a session summary with no pending or unmatched actions. Governance latencywas 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
unknownoutcomes in the e2e run are the honest result, and they mean repetition detectionon read-like tools currently relies on the workspace state hash alone, without completion evidence.
tool.execute.afterfires only on success is not documented. The adapter does not assume it,which is why non-shell outcomes stay
unknownrather than being read as successes.control fails open.
Python test suite. The bridge protocol it speaks is covered behaviorally.