Skip to content

fix(hook/opencode): support OpenCode V2, Desktop, and Windows (#3463, #3326) - #4187

Open
amnesiaof wants to merge 18 commits into
rtk-ai:developfrom
amnesiaof:fix/opencode-v2-compatibility
Open

amnesiaof wants to merge 18 commits into
rtk-ai:developfrom
amnesiaof:fix/opencode-v2-compatibility

Conversation

@amnesiaof

@amnesiaof amnesiaof commented Sep 21, 2026 •

Copy link
Copy Markdown

Summary

Refactors hooks/opencode/rtk.ts to be universally compatible with both OpenCode 2.0 and OpenCode 1.x, fixes compatibility with OpenCode Desktop, and resolves cross-platform path issues on Windows.

Closes #3898, Closes #3463, Closes #3326, Closes #2516, Closes #1993.

Problem

  1. OpenCode 2.0 incompatibility (feat: add opencode v2 plugin support (opencode2) #3463): OpenCode V2 expects an object with { id, setup } and ctx.tool.hook("execute.before", event => ...), replacing the legacy V1 signature.
  2. OpenCode Desktop failure (rtk init --opencode plugin silently does nothing on Desktop ($ is not a function) and on Windows (which not found) #3326): OpenCode Desktop runs in an Electron/Node environment where the Bun Shell $ helper is undefined, throwing TypeError: $ is not a function.
  3. Missing default export (OpenCode plugin missing default export — plugin never loaded by plugin loader #2516): OpenCode's module loader expects a default export; named exports were silently ignored.
  4. Windows path failure (OpenCode plugin (tool.execute.before) never fires on Windows — auto-rewrite broken #1993): The plugin invoked which rtk, which fails on Windows.
  5. Exit code 3 handling: rtk rewrite exits with status code 3 on success for advisory rewrites; standard Node child_process callbacks treat non-zero exits as errors, causing rewrites to be discarded.

Solution

  • Dual API compatibility: Exports a callable plugin function (for OpenCode 1.x) with .id and .setup properties (for OpenCode 2.0).
  • Node-native child_process: Replaced Bun $ with node:child_process.execFile with zero external dependencies.
  • Exit code 3 awareness: Captures and validates stdout regardless of whether child_process flags a non-zero exit code.
  • Robust binary resolution: Automatically discovers rtk across RTK_BIN, PATH, ~/.cargo/bin, ~/.local/bin, Homebrew, and Windows PATHEXT.
  • Zero blocking: Adheres to RTK's "Never Block" philosophy by cleanly passing through commands if rtk is missing or times out.

Verification

  • Tested in OpenCode 2.0 beta: execute.before fires and mutates event.input.command.
  • Tested in OpenCode 1.x: tool.execute.before hook functions identically.
  • Tested standalone with Node.js and Bun on Windows and Linux.
  • Tested behavior when rtk exits with code 3 on rewritten rules.

@amnesiaof
amnesiaof force-pushed the fix/opencode-v2-compatibility branch from a151413 to 9995652 Compare September 21, 2026 23:34
@rtk-wshm-sync-bot

Copy link
Copy Markdown

wshm · Automated triage by AI

📊 Automated PR Analysis

🐛 Type bug-fix
🟡 Risk medium

Summary

Refactors the OpenCode RTK hook plugin to support both OpenCode 2.0's { id, setup } / execute.before API and the legacy 1.x tool.execute.before API, replaces the Bun-only zx $ shell helper with node:child_process.execFile for compatibility with OpenCode Desktop (Electron) and Windows, adds robust rtk binary discovery, handles rtk's exit code 3, and updates related documentation.

Review Checklist

  • Tests present
  • Breaking change
  • Docs updated

Linked issues: #3463, #3326, #2516, #1993


Analyzed automatically by wshm · This is an automated analysis, not a human review.

@amnesiaof

Copy link
Copy Markdown
Author

Context & Comparison with other open OpenCode PRs

There are several open PRs addressing various OpenCode integration issues (#3463, #3330, #2819, #2705, #2566, #2153). This PR supersedes them by providing a single, zero-dependency, universal implementation that resolves all open failure modes at once:

PR / Issue export default (#2516) OpenCode Desktop / Electron (#3326) Windows PATH (#1993) OpenCode 2.0 API (#3463) Handles exit code 3 Notes
#2566 ✅ ❌ ❌ ❌ ❌ Added export default, but retains Bun $ (crashes in Desktop/Node).
#2153 ❌ Partial ❌ ❌ ❌ Uses which (breaks on Windows); misses export default.
#2705 ❌ Partial ✅ ❌ ❌ Great path discovery, but discards valid rewrites due to exitCode !== 0 check (ignores exit code 3); misses export default.
#2819 ✅ ❌ ❌ ❌ ❌ Adds large custom output truncation logic (400+ loc), tightly coupled to Bun runtime.
#3330 ❌ Partial ❌ ❌ ✅ Replaced $ with execFile, but misses export default and path discovery.
#3463 ✅ ❌ ❌ ✅ ❌ Adds a separate rtk-v2.ts and extra --opencode-v2 CLI flag instead of keeping a universal plugin file.
This PR ✅ ✅ ✅ ✅ ✅ Single universal file supporting both 1.x & 2.0, Desktop & CLI, Windows, Linux & macOS.

Key advantages of this approach:

  1. No new CLI flags required: Instead of introducing --opencode-v2 and maintaining two separate files (rtk.ts and rtk-v2.ts), the plugin uses a dual-signature (a callable plugin function for 1.x with .id and .setup properties for 2.0). rtk init -g --opencode continues to work seamlessly across all versions.
  2. Robust exit code handling: rtk rewrite exits with status 3 on success for advisory/ask rules. Standard Node child_process callbacks treat code 3 as an error; this PR properly inspects and accepts stdout when present.
  3. Desktop & Electron safe: Completely eliminates reliance on Bun's global $ helper, preventing runtime TypeError: $ is not a function in OpenCode Desktop.
  4. Adheres to "Never Block": If the binary is missing or child process fails, it cleanly passes through the original command.

@tomas-barros1 tomas-barros1 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.

AI code review — solid fix; two hardening items + tests to address

Reviewed hooks/opencode/rtk.ts and the doc changes against the linked issues and the OpenCode V1/V2 plugin APIs. All four issues are addressed in substance, and the key claims verify:

  • #3326 (Desktop / $ is not a function) — eliminated: no Bun $, no zx; node:child_process.execFile runs in Electron/Node.
  • #2516 (missing default export) — fixed (export default RtkOpenCodePlugin), and the old named export is kept so existing installs don't break.
  • #1993 (Windows / which) — fixed via path.delimiter + PATHEXT probing + extra dirs (~/.cargo/bin, ~/.local/bin, Homebrew); exts = [""] on POSIX keeps behavior sane. windowsHide: true is a nice touch.
  • #3463 (V2 API) — id/setup + ctx.tool.hook("execute.before", ...) matches the OpenCode V2 plugin docs, including mutating event.input.command on the mutable event.
  • Exit code 3 — verified against src/hooks/rewrite_cmd.rs: Allow→0, Ask→3, Default→3, Deny→2, Defer→1. Treating 3 as success is real and necessary.
  • rtk init wiring — src/hooks/init.rs embeds this exact file (include_str!("../../hooks/opencode/rtk.ts")), so the fix lands in rtk init -g --opencode on the next build. Worth a release note (existing installs must re-run init / upgrade).
  • Security: array argv to execFile ⇒ no shell interpolation; missing/unresponsive binary still passes through ("never block" preserved); zero dependencies is a real win for a file-based plugin.

Please address

  1. Timeout partial-stdout hazard (inline) — a child killed by timeout: 3000 can yield truncated stdout that this code would apply as the rewrite. Gate stdout on the error/exit code instead of ignoring it.
  2. V1/V2 dual-shape verification (inline) — the hybrid (callable function with attached id/setup) is clever, but you're shipping the only hook that doesn't prove it through the documented shape. One smoke test through the real V2 loader would settle it.
  3. No tests — this is the only agent hook in hooks/ without a test (hooks/hermes/tests/test_rtk_rewrite_plugin.py, hooks/claude/test-rtk-rewrite.sh, hooks/copilot/test-rtk-rewrite.sh all ship them). A zero-dep node:test suite covering path resolution, exit-3 acceptance, timeout discard, tool-name filter, and never-block pass-through would close the "Tests present" gap flagged by the wshm bot and protect the exit-code protocol from regressions.

Nits

  • (inline) cachedRtkPath freezes a failed lookup as null for the session; RTK_BIN with ~/$HOME isn't expanded.
  • The V2 setup warns when the binary is missing but the V1 path stays silent — minor asymmetry.
  • hooks/README.md's execFile snippet omits the bash/shell tool-name filter the real code applies (illustrative, but easy to copy wrongly).
  • Missing trailing newline at EOF (inline).

No hard logic blockers found — this does what it claims. With the two inline items addressed and a small test file, this is ready to merge.

@amnesiaof

Copy link
Copy Markdown
Author

Thanks for the thorough review and great catches!

All points have been addressed in the latest commit:

  1. Timeout & Partial-stdout Hazard:
    • Now explicitly validating !error.killed && !error.signal.
    • Strictly accepting only exit code 0 (error == null) and exit code 3 (error.code === 3). Any timeout or other exit code (such as Defer: 1 or Deny: 2) safely discards stdout and resolves to null.
  2. node:test Suite Added:
    • Added zero-dependency hooks/opencode/rtk.test.mjs verifying:
      • OpenCode V1 and V2 dual-shape contracts.
      • Tool-name filtering (bash / shell only).
      • Input edge cases (empty strings, non-string types).
      • Binary discovery with ~ home directory expansion.
      • Process exit code handling (accepts 0 and 3, rejects 1, 2, and timeout abortion).
      • V2 execute.before hook registration.
  3. Nits Resolved:
    • Added expandHome to expand ~ in RTK_BIN.
    • Prevented permanent null lock on cachedRtkPath if the binary was missing at initial call.
    • Synchronized warning in V1 when rtk binary is missing.
    • Updated the hooks/README.md snippet to include the tool-name filter.
    • Ensured clean trailing newlines at EOF.

@codebude

Copy link
Copy Markdown

I tested the installed rtk.ts from this PR against OpenCode 2.0.16 (stable, opencode serve) and the plugin fails to load. The V2 loader rejects the hybrid shape:

level=WARN message="failed to load plugin" target=~/.config/opencode/plugins/rtk.ts
cause="PluginModule.LoadError: Plugin must export a default definition with an id and an
       effect or setup function. (cause: SchemaError(Expected object at [\"default\"]))"

Root cause: the 2.0.16 plugin loader validates module.default against a schema that accepts only plain objects — { id, setup } or { id, effect } (verified by reading the loader code inside the compiled binary). A function with .id/.setup bolted on as properties is not an object to that guard, so validation fails before setup() is ever invoked. The V2 branch of the dual-shape therefore never activates — the hook is silently absent on all current 2.x releases.

This is consistent with the official V1→V2 migration docs, which state that V2 plugins "default-export a definition with an id and setup(ctx)" and show the dual-support shape as an object that spreads Plugin.define({...}) and adds a server() method — not a callable function with attached properties.
For reference, the minimal shape that loads and registers the hook on 2.0.16 (confirmed working — no load error, execute.before registered):

export default {
  id: "rtk",
  async setup(ctx) {
    if (!resolveRtkPath()) {
      console.warn("[rtk] rtk binary not found — plugin disabled")
      return
    }
    await ctx?.tool?.hook("execute.before", async (event) => {
      const input = event?.input
      if (!input || typeof input !== "object") return
      const rewritten = await tryRewriteCommand(event?.tool, input.command)
      if (rewritten) input.command = rewritten
    })
  },
}

Since 2.x is current, I'd suggest making the object definition the default export and dropping the callable-function path (or, if 1.x support must be kept, exporting the object above while leaving the V1 function as a named export for 1.x loaders — though per the docs, 1.x V1 object entrypoints are supported in 1.18.29+, so a plain object with both setup and server keys is the sanctioned dual-shape).

This also addresses the smoke-test gap flagged in review: a test that feeds the default export through the actual 2.x loader schema (or a simple typeof === "object" && !Array.isArray + shape assertion) would have caught this before merge.

@amnesiaof

Copy link
Copy Markdown
Author

Thanks for testing this against opencode serve 2.0.16 and highlighting the schema check!

Everything is now updated, hardened, and verified locally:

  1. Plain Object Default Export:
    • default is now a plain object { id, setup, server } conforming to the OpenCode 2.x loader schema check (typeof === "object").
  2. Dual-Shape Compatibility:
    • Retained the server() method on the object per the official migrate-v1 docs, ensuring OpenCode 1.18.29+ remains supported.
  3. Comprehensive node:test Suite (6 pass / 0 fail):
    • Explicit schema test asserting a non-null, non-array object with id and setup(ctx).
    • Tool-name filtering (bash/shell only).
    • Input sanitization (guards against empty strings and non-string values).
    • Binary resolution with ~ expansion in RTK_BIN.
    • Process execution and exit code handling (accepts 0 and 3, discards 1 and 2, guards against timeout partial output).
    • Uses process.execPath for mock binary execution, running cleanly across Windows, macOS, and Linux with zero deprecation warnings.

Ready for re-test!

…up logic

- Consolidate command mutation logic between V1 and V2 entrypoints into a shared helper
- Unify missing binary warning between setup() and server()
- Simplify expandHome and path discovery loops
ikchifo added a commit to ikchifo/rtk that referenced this pull request Sep 25, 2026
Carries rtk-ai#4187 (OpenCode V2, Desktop, and Windows support)
until it merges upstream. Drop this commit when syncing after the PR
lands; the eventual upstream merge must be a no-op against these files.
@jchuder

jchuder commented Sep 27, 2026

Copy link
Copy Markdown

This PR also addresses #3898.

I originally opened #3899 for the same OpenCode 2.0 compatibility issue, but this PR now covers the problem more completely. I closed #3899 and further work on #3898 will be tracked here.

If appropriate, please add Closes #3898 so the issue is closed when this PR is merged.

@amnesiaof

Copy link
Copy Markdown
Author

This PR also addresses #3898.

I originally opened #3899 for the same OpenCode 2.0 compatibility issue, but this PR now covers the problem more completely. I closed #3899 and further work on #3898 will be tracked here.

If appropriate, please add Closes #3898 so the issue is closed when this PR is merged.

Thanks for the support and for consolidating the efforts here! I've added Closes #3898 to the PR description.

@vmvarela

Copy link
Copy Markdown

I need this !

@aliajboy

Copy link
Copy Markdown

Thanks but is it gonna merge anytime soon?

@amnesiaof

Copy link
Copy Markdown
Author

@pszymkowiak @FlorianBruniaux @aeppling @KuSh @TaKO8Ki Could you please take a look?

@abbasnaqdi

Copy link
Copy Markdown

+1 Please...

# Conflicts:
#	docs/guide/getting-started/supported-agents.md
#	hooks/opencode/README.md
#	hooks/opencode/rtk.ts
@amnesiaof
amnesiaof force-pushed the fix/opencode-v2-compatibility branch from 5c9e798 to 96a538f Compare October 5, 2026 18:37
…51.1

KuSh's review of rtk-ai#2426 named two gaps that also apply here: the plugin
decided usability from the file's existence, and set no minimum version.

existsSync only proves a file is there. A wrong-arch binary, a broken
install, or a stale RTK_BIN all pass that check and then fail on every
single tool call. Spawning `rtk --version` once per session is the check
that actually means something, and the answer is cached per binary.

The floor is 0.51.1, not 0.51.0: `rtk hook opencode` was added in rtk-ai#4349
and exists in no released version -- v0.51.0's src/main.rs has no
HookCommands::Opencode variant. Without the gate a 0.51.0 user gets a
plugin that registers hooks, delegates to a missing subcommand, and
silently passes every command through.

A develop build reports 0.49.0 because release-please only bumps
Cargo.toml on master, and the warning it gets is accurate: such a build
has no release version. An unparseable banner is not evidence of an old
rtk, so it passes and the delegation call still fails open.
KuSh, on rtk-ai#2426: "skipping commands that already start with `rtk `".

`rtk git status` reaching the hook means the agent already used rtk --
either carried over from an earlier rewrite or typed by the user. Handing
it back to `rtk hook opencode` cannot improve it, and it costs a process
spawn on every such call.

hooks/pi/rtk.ts already does this with a bare `startsWith("rtk ")`. The
regex here additionally tolerates leading whitespace, since the shell
ignores it and the command may arrive that way.
KuSh, on rtk-ai#2426: "honouring RTK_DISABLED".

`RTK_DISABLED=1` is the escape hatch hooks/README.md documents for users
who want a run without rtk, and hooks/pi/rtk.ts already checks it. Without
it the OpenCode plugin was the one TS hook that could not be turned off
short of editing the file.

Checked per call rather than at setup, for the same reason pi does: the
variable is inherited from whatever launched OpenCode, so a user can
start a session with it set and get a clean baseline. Only the exact
string "1" disables rewriting -- "0" and "true" are not the documented
form and must not silently turn the plugin off.

@KuSh KuSh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Round 1 of 3 — CHANGES REQUESTED

Claim: hooks/opencode/rtk.ts loads and rewrites on OpenCode 2.x and 1.x, in Desktop (no Bun $), on Windows (no which), delegating to rtk hook opencode (#4349).
Scope: accept if narrowed to "OpenCode 2.x and 1.x, with rtk resolved from PATH (PATHEXT on Windows); 1.x ≤ 1.3.3 served by a legacy plugin". The V2 fix is real and needed: develop's plugin fails to load on 2.0.22. Finding rtk outside PATH becomes a follow-up issue (below). (round 1, frozen)
Ran: real OpenCode driven by a mock model that issues one bash/shell tool call, with the plugin installed by each build's own rtk init -g --opencode: develop df39e33d7 (#4349 merged) vs develop + bedcc2cf7, binaries verified to differ. OpenCode 2.0.22, 1.18.34, 1.18.10, 1.3.4, 1.3.3, 1.1.4 (1.0.142 to 1.18.28 on the earlier head).

  • Rows: ls -la; rtk ls && ls -la; OpenCode rules where the rewrite would flip the verdict, in V1 permission.bash, permission.shell, the V2 permissions list (also with a wildcard action and mixed with the legacy map), and agent-scoped agent.build.permission and agents.build.permissions; rtk only in ~/.cargo/bin; RTK_BIN set to a directory; RTK_DISABLED=1; the develop build's real rtk 0.49.0 banner and a release-like 0.51.1 one.
  • Invariants: non-default targets, every decision. Savings: n/a, no filter changes.

Pins: 24 mutations, 10 killed, 14 survived (1 of them equivalent against the real rtk), listed in blocker 3.
Matrix: executed this round. Pin check: executed this round.
CI @bedcc2cf7: all green.

Blocks merge (3, frozen at round 1)

  1. hooks/opencode/rtk.ts:52, :186, :142, :116 — the plugin's own checks disagree with what actually runs. Six sites. c and d implement what I suggested when closing #2426. After testing, my first proposal doesn't work in some cases and needs a better solution, so please read those two as a correction of my suggestion, not as something you got wrong. Evidence on 1.18.10 and 2.0.22, develop vs this PR:

    • a. RTK_BIN and the fallback dirs. With rtk only in ~/.cargo/bin, develop disables itself and ls -la runs. This PR rewrites it, and the bash tool returns /usr/bin/bash: line 1: rtk: command not found. The tool doesn't source .profile/.bashrc/.bash_profile; I tried all three exporting the directory.

    • b. RTK_BIN pointing at a directory, with rtk on PATH. Develop rewrites. This PR disables itself for the session.

    • c. The 0.51.1 floor. It disables the plugin on every develop build, including the binary that installed it. Develop reports rtk 0.49.0, which is also what cargo install --git https://github.com/rtk-ai/rtk builds, since develop is the default branch. OpenCode logs [rtk] rtk rtk 0.49.0 has no \hook opencode` subcommand (need >= 51.1) — plugin disabledandls -laruns unrewritten. That message is wrong three ways: the binary does have the subcommand, the version is0.51.1, not 51.1, and rtkis doubled. A pre-#4349 binary reports the samertk 0.49.0, so no version number can tell the two apart. rtk hook opencode --helpcan: exit 0 on develop, exit 2 on the pre-#4349 binary. So a probe of the subcommand is the better form of the minimum-version check I suggested, and one call also covers the binary-runs check that--version` was for.

    • d. The rtk skip. It drops rewrites develop makes. rtk hook opencode 'rtk ls && git status' answers rtk ls && rtk git status, and rtk ls && ls -la comes back with a raw ls -la half here but rewritten on develop. A bare rtk ls already answers {}, so the skip only saves one process. This is the other part of my #2426 suggestion that testing showed doesn't hold: it should go.

    • e. The agent is never passed. On 2.0.22, execute.before arrives as {tool, sessionID, agent, messageID, id, input}, and rtk hook opencode --agent <name> already reads agent.<name>.permission on top of the root rules. But runHookOpencode spawns ["hook", "opencode", command], so rtk judges the rewrite against root rules only, while OpenCode applies the agent's. This is the agent-scoped case #4195 left open after #4349. With agent.build.permission.bash = {"*": "allow", "ls *": "ask"}, OpenCode alone prompts for ls -la. This PR rewrites it to rtk ls -la, which the agent's * allows, so the prompt is gone. With {"*": "deny", "ls -la": "allow"}, OpenCode alone runs ls -la, and this PR's rtk ls -la is denied. Passing event.agent as --agent <name> when it's a non-empty string fixes both: a patched copy of this head keeps the prompt, runs ls -la in the second case, and still rewrites when no agent rule applies. 1.x has no agent field (tool.execute.before gets {tool, sessionID, callID}), so the V1 path stays root-only. On 1.18.34 the agent-scoped ask is still silenced, with or without the change. Please say so in hooks/opencode/README.md.

    • f. rtk reads only V1-shaped rules. rtk hook opencode (src/hooks/permissions_opencode.rs:87 and load_opencode_rules) reads permission.bash and agent.<name>.permission. OpenCode 2.0.22 also applies:

      • V2's native "permissions": [{action, resource, effect}] list, where the action is shell or a wildcard such as *;
      • permission.shell;
      • "agents": {"<name>": {"permissions": [...]}}.

      None of these reach rtk, and develop never meets them, because its plugin doesn't load on V2. This PR is what exposes those users, so reading them belongs here. Measured, OpenCode alone vs this PR:

      • permissions with * denied and ls -la allowed: ls -la runs vs rtk ls -la is denied. Same with permission.shell.
      • {action: "*", resource: "ls *", effect: "ask"}: prompts vs silenced.
      • agents.build.permissions with an ask on ls *: prompts vs silenced. Still silenced with 1e alone, because rtk doesn't read that list.

      Order matters for "last match wins". OpenCode applies the legacy permission map first and the permissions list after it, whatever their order in the file: legacy allow plus list deny is denied, legacy deny plus list allow is allowed. Per the V2 docs, global rules come before project rules and agent rules come last. The docs also list agents defined in .opencode/agents/<name>.md / ~/.config/opencode/agents/<name>.md with permissions: frontmatter; I haven't run those. This is Rust in the same file #4458 refactors, so whichever lands second rebases.

    A correct fix passes these rows:

    • rtk only in ~/.cargo/bin or only at RTK_BIN → ls -la runs raw or through rtk, never command not found.
    • RTK_BIN = a directory with rtk on PATH → rewritten.
    • A develop build (rtk 0.49.0 with hook opencode) → rewritten. A pre-#4349 binary → disabled, with a message that names the missing subcommand.
    • rtk ls && ls -la → rtk ls && rtk ls -la.
    • 2.0.22 with agent.build.permission rules: the ask on ls * still prompts, and * denied / ls -la allowed still runs ls -la.
    • 2.0.22 with each V2 form in 1f: same outcome as OpenCode alone, including the legacy-map-then-list order.

    For (a), two shapes meet the rows: resolve from PATH only (keeping PATHEXT, which is the #1993 fix), or keep the discovery and make the spawned shell resolve the same binary. The first matches the narrowed claim.

  2. hooks/opencode/rtk.ts:236 — OpenCode ≤ 1.3.3 regresses. Loaders before 1.3.4 ("single target plugin entrypoints", 2026-03-29) call every export, default included, as fn(input):

    • 1.1.4: OpenCode exits 1 at startup (TypeError: fn3 is not a function).
    • 1.3.3: failed to load plugin, no rewrite.
    • Develop's current plugin rewrites on both.

    V2 rejects a function default and these loaders reject an object, so no single file serves both. We'd like to keep develop's current rtk.ts as a legacy plugin, the way --claude-md keeps the legacy Claude Code mode. Ship it alongside the new one (embedded like the current file), and have rtk init -g --opencode choose between them:

    • OpenCode ≥ 1.3.4 or 2.x (from opencode --version at install time) → the new plugin.
    • OpenCode ≤ 1.3.3 → the legacy plugin, with a line saying so.
    • An explicit legacy option → the legacy plugin whatever the version, for installs where opencode isn't on PATH at init time.
    • opencode not found and no option → the new plugin.

    Both land at ~/.config/opencode/plugins/rtk.ts, so uninstall and rtk init --show keep working unchanged. The floor and the option go in hooks/opencode/README.md and supported-agents.md. Rows: the legacy file rewrites on 1.1.4 and 1.3.3, the new one on 1.3.4 and 2.0.22, and a Rust test pins the version split at 1.3.3 / 1.3.4, including 2.0.22 and an unparsable string.

  3. hooks/opencode/rtk.test.mjs:204 — unpinned behaviour. Each edit below leaves the suite green:

    • Hook wiring: no test ever invokes a registered hook. handleToolHook(e?.tool, e?.args) in setup, input?.args in server, "tool.execute.after" as the V1 key, and container.command = rewritten without the if (which writes null over every command rtk leaves alone) all survive.
    • Usability gates: dropping ensureRtkUsable() from setup or server, if (false) for the probe-null gate, and if (false) for the floor gate all survive.
    • Guards whose tests claim to pin them:
      • Deleting if (isAlreadyRtk(command)) return null survives. The test sends rtk rewrite, which the mock doesn't answer, so null comes back anyway.
      • Deleting the RTK_DISABLED check survives. That case runs before the hook mock is written, so node fails and returns null anyway.
      • Deleting the probe cache survives. The test resets it before the second call.

    The tests must, with rtk given through RTK_BIN as the suite now does:

    • V2: call the callback setup registered with the shape 2.0.22 sends, {tool: "shell", sessionID, agent, messageID, id, input: {command: "ls -la"}}. Assert input.command === "rtk ls -la", and that a command answered {} stays as typed.
    • V1: call (await server())["tool.execute.before"] with 1.18.34's shape, ({tool: "bash", sessionID, callID}, {args: {command: "ls -la", description}}), with the same two assertions.
    • An unusable binary (probe fails, or the capability check from 1c fails): setup registers nothing and server() returns no hook.
    • The agent (1e), with a hook mock that echoes its argv: the V2 callback, given agent: "build", spawns opencode --agent build ls -la; the V1 hook spawns opencode ls -la. I checked this shape against a patched copy: it passes there, fails on this head, and fails again once e?.agent is dropped.
    • The RTK_DISABLED and probe-cache tests must fail when their line is deleted.
    • Rust (1f): a permissions_opencode test per V2 form, and one pinning that the legacy map comes before the list.

    Each mutation above must turn one of them red.

Optional — will not hold merge

  • hooks/README.md: the snippet assigns args.command inside a non-awaited execFile callback, so copying it rewrites nothing.
  • rtk.test.mjs writes its hook and rewrite mocks into process.cwd(), which clobbers files of that name. It also restores unset RTK_BIN/RTK_DISABLED as the string "undefined". mkdtempSync and delete avoid both.

Filed as follow-ups

  • #4462 OpenCode plugin: no rewrite when rtk is not on OpenCode's PATH (Desktop launched from the macOS GUI). It's cut by the narrowed claim, and it carries #2039's case, which was closed into #3326. Filed.

Checked and correct — no need to re-verify

  • OpenCode 2.0.22: develop fails with PluginModule.LoadError … Missing key at ["default"]. This PR loads and rewrites ls -la (with a banner the floor accepts).
  • Delegation keeps #4349's intent on 1.x. With permission.bash set to * denied and ls -la allowed, ls -la runs as typed. With * allowed and ls * on ask, the prompt is kept. Same as develop on 1.18.34, and on 2.0.22 with the bash key.
  • RTK_DISABLED=1 in OpenCode's environment passes commands through. Develop's plugin keeps rewriting there, so this is new and works.
  • The command reaches rtk hook opencode as one argv element, and a non-JSON or failed answer passes the command through.
  • The hook mapping matches the real event shapes: V2 event.tool === "shell", event.input.command; V1 input.tool === "bash", output.args.command.

Your questions

  • None open.

Next

1c and 1d are corrections of my own #2426 suggestion. Then fix 1a, 1b, 1e and 1f, add the legacy plugin for 2, and add the tests in 3. After your push I'll rerun the rows above on the new head.
Rounds: 1/3. Threads: 3 open (waiting on the author), 0 resolved this round.

Comment thread hooks/opencode/rtk.ts Outdated
if (existsSync(expanded)) return (cachedRtkPath = expanded)
}

const dirs = [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocker 1 (a–f): the plugin's own checks disagree with what actually runs. Discovery outside PATH here, the 0.51.1 floor, the rtk skip, the missing --agent, and the V2 rule forms rtk doesn't read. The review body has the evidence and the rows a fix has to pass.

Comment thread hooks/opencode/rtk.ts
* Exports a plain object with `id` and `setup(ctx)` matching OpenCode 2.x Schema validation,
* with a `server()` method for OpenCode 1.x (1.18.29+) dual-shape compatibility.
*/
const RtkOpenCodePlugin = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocker 2: with an object default export, OpenCode 1.1.4 exits 1 at startup (fn3 is not a function) and 1.3.3 fails to load the plugin, while develop's plugin rewrites on both. The review body has the legacy-plugin ask.

Comment thread hooks/opencode/rtk.test.mjs Outdated
_resetCachedRtkPath()
process.env.RTK_BIN = process.execPath
assert.equal(await tryRewriteCommand("bash", "rewrite"), "rtk git status")
assert.equal(await tryRewriteCommand("bash", "rtk rewrite"), null)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocker 3: this assertion passes without the isAlreadyRtk guard, because the mock doesn't answer rtk rewrite, so null comes back anyway. No test invokes a registered hook either. The review body lists the surviving mutations and the test rows.

The skip drops rewrites develop makes and saves one process. `rtk hook
opencode 'rtk ls && git status'` answers `rtk ls && rtk git status`, and a
bare `rtk ls` already answers `{}`, so the guard only ever suppressed the
rewrite of the bare halves in a chain.

Reviewed as part of rtk-ai#4187 round 1.
…ward the agent

KuSh's review round 1, items 1a, 1b, 1c and 1e.

1c — the version floor was the wrong instrument, and my own floor made it worse.
`--version` cannot tell the two populations apart: a develop build reports
`rtk 0.49.0` because release-please only bumps Cargo.toml on master, and so
does every pre-rtk-ai#4349 release. The 0.51.1 floor therefore disabled the plugin on
the very binary that had just installed it, while letting a real 0.51.1-rc
through. `rtk hook opencode --help` separates them: exit 0 with the subcommand,
exit 2 without it. One call, and it subsumes "does this binary even run" — a
broken or wrong-arch binary fails to spawn at all. The floor and the version
comparison go with it, and with them the warning that printed `rtk rtk …`,
lost the leading zero, and claimed a present subcommand was missing.

1a/1b — binary discovery narrows to PATH. The RTK_BIN, ~/.cargo/bin,
~/.local/bin, Homebrew and expandHome search was the original Windows fix, but
OpenCode's shell tool runs commands with OpenCode's own PATH and never sources
.profile, .bashrc or .bash_profile — an rtk found anywhere else gets rewritten
into a command that comes back `rtk: command not found`. Finding it anyway is
worse than not rewriting. KuSh triaged that as rtk-ai#4462 rather than blocking on it;
this commit implements the narrowed claim. A PATH entry that is a directory is
skipped instead of frozen as the answer, which used to disable the plugin for
the whole session.

1e — OpenCode 2.x sends event.agent; rtk already reads agent.<name>.permission
on top of the root rules, which is what OpenCode itself applies. Without it the
rewrite silenced an agent-scoped ask on `ls *` and an agent-scoped deny on
`ls -la` never saw the command. 1.x sends no agent field, so that path stays
root-only and the README says so.

The tests are rewritten around the two things the old suite never did: it never
invoked a registered hook, and it mocked rtk by writing into process.cwd().
node is now copied into a temp directory as `rtk`/`rtk.EXE` with a `hook`
script beside it and the suite chdirs there, so the shipped code path runs
end to end — PATH discovery, the probe, argv construction, and the callbacks
OpenCode 2.0.22 and 1.18.34 actually call. Both entrypoints are invoked with
their real event shapes, an unusable rtk is pinned to register nothing, the
agent is asserted in the argv, and the probe cache and RTK_DISABLED checks are
pinned against a live rtk so deleting either line fails the suite. 13 of 13
mutations from the review die on it.

`hooks/README.md`'s OpenCode snippet assigned `args.command` from inside a
non-awaited execFile callback, so anyone copying it got a plugin that rewrites
nothing. It awaits now.
KuSh's review round 1, item 2. Installing the 2.x plugin on an old OpenCode
breaks it: loaders before 1.3.4 call every export as `fn(input)`, and the plugin
default-exports a plain object — 1.1.4 exits 1 at startup with `fn3 is not a
function`, 1.3.3 logs `failed to load plugin`. The two shapes cannot be one
file, because the 2.x loader is the mirror image and rejects a callable default.

So develop's plugin comes back as `hooks/opencode/rtk-legacy.ts`, embedded
alongside the current one, and `rtk init -g --opencode` picks between them from
`opencode --version`: <= 1.3.3 gets the legacy file and a line saying so, >= 1.3.4
and 2.x get the current one, and a missing or unparsable OpenCode gets the
current one — it works on every released version, so that is the safe default.
`--opencode-legacy` forces the legacy file for anyone whose setup hides the
binary from init.

The version probe lives in `ensure_opencode_plugin_installed` behind an
AtomicBool rather than a parameter, because that function has four call sites
across three init modes and none of them otherwise know anything about OpenCode
versions.

The tests pin the split at both sides of 1.3.4 (plus 2.0.22, a 0.x version, and
three unparsable banners), and pin each file's export shape, so a future edit
that turns the legacy file into an object — or the current one into a function —
fails here rather than at someone's startup.
KuSh's review round 1, item 1f, and the handoff note aeppling left on rtk-ai#4349.

rtk only read the legacy `permission` map, so a 2.x user who wrote the rules
OpenCode 2 actually documents had them silently ignored — the plugin then judged
rewrites against an empty rule set and returned rewrites that OpenCode would
have asked about or denied. Three shapes were missing:

- `permissions: [{action, resource, effect}]`, the 2.x native list. Entries for
  other tools are dropped; one without a `resource` covers every command.
- `permission.shell`, the 2.x name of the `bash` axis. Rules are stored under
  the axis the evaluator matches on, so both spellings land on `bash`.
- `agents.<name>.permissions`, the 2.x agent block. 1.x's `agent.<name>` still
  loads, after it.

Ordering is load-bearing and is now explicit: the legacy map first, the 2.x list
after it, within each scope, regardless of the order the file declares them. A
legacy allow plus a list deny has to come out denied — otherwise a user who
loosened the old map and then denied in the new one would be handed the allow.
Global before project, and the agent block last, both unchanged.
KuSh's review round 1, item 2, reconsidered after reading OpenCode's own docs.

The legacy file existed because the object-shaped plugin cannot load on old V1:
those loaders call every export as `fn(input)`, and an object is not callable. I
answered that with a second plugin file plus a version probe, which means
shipping a Bun-`$`-only, Windows-broken plugin forever, wired to a boundary I had
measured rather than one OpenCode publishes.

https://opencode.ai/v2/docs/build/plugins#support-v1 answers this directly. The
dual `setup()`/`server()` default export is the shape OpenCode recommends, and
the page puts the object form at 1.18.29 — older V1 releases "may expect function
exports instead". The same page says to remove the V1 implementation once the
support window closes, and the migration guide says the same thing again. So the
floor is 1.18.29, which is also the last 1.x line.

`needs_legacy_plugin` had it at 1.3.4, from the review's own matrix: 1.3.3 fails,
1.3.4 works. That is the version where the loader changed, not the version
OpenCode supports, and between 1.3.4 and 1.18.29 we were claiming releases the
vendor does not.

So `rtk-legacy.ts`, `OPENCODE_PLUGIN_LEGACY`, the override flag and the selector
are gone. `rtk init -g --opencode` writes the one plugin and, when it can read
`opencode --version`, names the version it found and the floor if the install
will not load. An OpenCode we cannot ask stays silent rather than being told off
for a banner we could not parse.

Also repairs the encoding of `hooks/opencode/rtk.ts`, which had picked up mangled
bytes for every em dash and ellipsis in its comments when it was rewritten in an
earlier commit. Same characters, correct UTF-8.
@amnesiaof

amnesiaof commented Oct 6, 2026 •

Copy link
Copy Markdown
Author

Item 2 is a scope disagreement: you asked for the legacy plugin, 9513636 drops it.

OpenCode's docs put the floor higher than we did. The "Support V1" page at https://opencode.ai/v2/docs/build/plugins#support-v1 says the object default export works from 1.18.29, that older 1.x releases expect function exports, and that the V1 implementation should be removed once the support window closes. 1.18.29 is the last 1.x release, so that is current 1.x plus 2.x.

Our 1.3.4 came from your matrix. That is where the loader changed, not what OpenCode claims to support.

The cost: on 1.3.3 and older the plugin stops working where develop's works. That is a real regression on a version you tested, so the scope call is yours to make. What I am asking for is current 1.x and 2.x, not the 1.x that predates the object export.

Also confirmed from the v1 docs: there is no permission.ask hook. The events are permission.asked and permission.replied, and those are notifications.

…eady has

A review pass over every md in the repo turned up the plugin's own doc comment
still describing the file this PR deleted. `hooks/opencode/rtk.ts` said 1.3.4
and named `rtk-legacy.ts` as what `rtk init` installs for older loaders; neither
is true since 9513636. It now says 1.18.29, cites the V2 plugin docs for that
number, and describes what init actually prints.

The floor was documented in two files out of eight that describe OpenCode, so
anyone on 1.3.4 reading `README.md`, `TECHNICAL.md`, `hooks/README.md` or the
Copilot awareness table would conclude the plugin works there. All four now name
the floor.

`hooks/opencode/README.md` described the permission rules as "last match wins"
over `opencode.json`/`.jsonc` and did not name the V2 forms, though the Rust
side has read them since 0c1cb3a. The order is load-bearing rather than
last-match, so the text now gives it: legacy map and `agent.<name>.permission`
first, then the V2 list, `permission.shell` and `agents.<name>.permissions`,
whatever their order in the file, global before project and agent last.

`troubleshooting.md` told people to re-run `rtk init` when OpenCode was not
rewriting. On anything older than 1.18.29 that cannot help, and the symptom is
different: OpenCode exits 1 at startup, or logs `failed to load plugin`. It now
says so, and adds the other case worth checking, an rtk that OpenCode's shell
tool cannot spawn because it is not on the PATH OpenCode sees.

The OpenCode snippet in `hooks/README.md` builds argv by hand, so it shows the
`--agent` element the paragraph above it describes.
The review pass found the plugin claiming to "honour RTK_DISABLED=1" without
saying which of the two forms that is, and they are not the same thing.

The per-command form, `RTK_DISABLED=1 git status`, is an env prefix inside the
command string. rtk never reads it from an environment: `cmd_has_rtk_disabled_prefix`
in the rewrite engine looks for it as a token prefix, and because this plugin
hands the command string through untouched, that form already works through the
delegation path and needs no code here.

What the plugin checks is `process.env.RTK_DISABLED`, OpenCode's own process
environment. That one is session-scoped rather than command-scoped, and it is
the same check hooks/pi/rtk.ts:124 makes.

The comment in rtk.ts claimed the env var was "the documented escape hatch
(hooks/README.md)", which reads as if it were the documented form. It is a
second one, and now says so.
@amnesiaof
amnesiaof requested a review from KuSh October 6, 2026 15:10
@sandeep-chaps

Copy link
Copy Markdown

when will this PR be merged?

# Conflicts:
#	docs/contributing/TECHNICAL.md
@Deroswent

Copy link
Copy Markdown

when will this PR be merged?

It appears this won’t happen anytime soon. The developers intend to include support for both v1 and v2 in a single release, even though OpenCode v1 is at EOL and is no longer supported or actively developed.

Why the RTK developers are doing this remains a mystery to everyone. But for the sake of supporting the outdated OpenCode v1, which has reached its end-of-life — they are not releasing RTK for the current v2 version and are blocking the project’s compatibility with the most popular coding agent.

@abbasnaqdi

Copy link
Copy Markdown

This compatibility should have been addressed before the stable release of opencode2. It has remained unresolved for months, despite being a critical issue.

Could the team prioritize reviewing, merging, and resolving these issues? opencode2 is a core development tool, not a secondary harness, and its compatibility deserves timely attention.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

10 participants