// rtk.ts — route shell commands through `rtk rewrite` to cut token usage. // // OpenCode 2.x plugin, OWNED BY ai-stack (design D1/D2, "ai-stack owns the rtk plugin // port"). The upstream rtk project installs a V1 plugin via onboard-rtk.sh; V1 plugins // do not run in V2, and the fleet is not blocked on an upstream release. This is a // fork of that integration, not a reimplementation: every rewrite DECISION still comes // from the `rtk` binary's own Rust rule registry, so rules never need maintaining here. // // HOOK CHOICE (V1 -> V2): // V1 registered `tool.execute.before`, checked `input.tool === "bash"`, and MUTATED // `output.args.command` in place — reaching into a tool's argument object, which was // never a stable contract. V2's ctx.shell.hook("create.before") is the supported // interception point and hands over the real command (plus cwd, env, timeout, shell) // before execution. // // BEHAVIOUR CHANGE, deliberate: the shell hook fires for shell creation rather than // for every tool whose name happens to be "bash". A non-shell tool that previously // matched the name check no longer gets rewritten. That is a correctness improvement, // but it IS a change — which is why the verifier asserts a command is actually // rewritten rather than only asserting the plugin loaded. // // NO `ctx.$`. The V1 plugin ran the binary through the context's shell helper. V2's // plugin context has NO `$` member (verified against 2.0.31: the context exposes // agent, event, permission, session, shell, tool, … and no `$`). Calling it would throw // inside the availability probe, land in the catch below, and SILENTLY DISABLE the // plugin — it would load cleanly and never rewrite anything. We use node:child_process // directly instead. // // FAILURE CONTRACT (spec: "Plugin failure is non-fatal"): if the `rtk` binary is not on // PATH, rewriting is disabled with exactly ONE diagnostic and the session starts // normally. A rewrite that errors passes the original command through untouched — a // token optimiser must never be able to change what a command does, or block it. import { Plugin } from "@opencode/plugin" import { execFile } from "node:child_process" import { promisify } from "node:util" const run = promisify(execFile) export default Plugin.define({ id: "ai-stack.rtk", async setup(ctx) { try { await run("rtk", ["--version"], { timeout: 5000 }) } catch { console.warn("[rtk] rtk binary not found in PATH — command rewriting disabled") return } await ctx.shell.hook("create.before", async (event) => { const command = String(event.command ?? "") if (!command) return try { // Pass the command as a single ARGV element, never through a shell: it is // untrusted text and must not be re-interpreted on the way to rtk. const { stdout } = await run("rtk", ["rewrite", command], { timeout: 10000 }) const rewritten = stdout.trim() // Only substitute a non-empty, genuinely different result. An empty stdout // (binary erroring out, rule miss) must leave the command alone rather than // replacing it with nothing. if (rewritten && rewritten !== command) event.command = rewritten } catch { // rtk rewrite failed — pass the original through unchanged. } }) }, })