Repository navigation
Description
Summary
Mutations to output.args inside a tool.execute.before plugin hook are not applied to the arguments actually passed to the tool execute function. The hook fires correctly and the local output.args object reflects the new value, but Tool.execute(b, ctx) still uses the original, unmodified b.
This makes it impossible for plugins to rewrite tool arguments (e.g. rewriting a shell command before execution), which the documented hook contract implies should work.
Environment
- OpenCode:
1.18.9 @opencode-ai/plugin:1.15.13- Platform: macOS (darwin)
How tool.execute.before is documented
The type contract in @opencode-ai/plugin/dist/index.d.ts:
"tool.execute.before"?: (input: {
tool: string;
sessionID: string;
callID: string;
}, output: {
args: any;
}) => Promise<void>;The plugin docs (https://zread.ai/sst/opencode/12-plugin-system-and-lifecycle-hooks) describe the (input, output) pattern as:
Hooks follow the two-argument
(input, output)pattern, whereinputcarries read-only context, whileoutputis a mutable object that plugins can modify. This design allows multiple plugins to chain modifications.
The runtime trigger implementation passes the same output reference to each hook and returns it:
const trigger = Effect.fn("Plugin.trigger")(function* (name, input, output) {
const s = yield* InstanceState.get(state)
for (const hook of s.hooks) {
const fn = hook
if (!fn) continue
yield* Effect.promise(async () => fn(input, output)) // same ref
}
return output
})And in packages/opencode/src/session/tools.ts, the call site is:
yield* i.trigger("tool.execute.before",
{ tool: u.id, sessionID: F.sessionID, callID: F.callID },
{ args: b });
let a = yield* u.execute(b, F); // b is unchanged even if the hook mutated output.argsBased on the documented contract, mutating output.args inside the hook should change what execute(b, ...) receives.
Reproduction
1. Create a minimal plugin
~/.config/opencode/plugins/repro.ts:
import type { Plugin } from "@opencode-ai/plugin"
import { appendFileSync } from "fs";
export const Repro: Plugin = async () => {
return {
"tool.execute.before": async (input: any, output: any) => {
const tool = String(input?.tool ?? "").toLowerCase();
if (tool !== "bash" && tool !== "shell") return;
const args = output?.args;
if (!args || typeof args !== "object") return;
const command = (args as Record<string, unknown>).command;
if (typeof command !== "string" || !command) return;
// Definitive proof: replace with a fixed echo
(args as Record<string, unknown>).command = "echo PROOF_REWRITE_WORKS_12345";
appendFileSync("/tmp/repro.log",
`HOOK: rewrote "${command}" → "${(output.args as any).command}"\n`);
},
};
};Register it in opencode.json:
{ "plugin": ["file:///Users/<you>/.config/opencode/plugins/repro.ts"] }2. Restart OpenCode and run any bash command
$ ls /tmp
<normal ls output — NOT "PROOF_REWRITE_WORKS_12345">
3. Check the log
$ cat /tmp/repro.log
HOOK: rewrote "ls /tmp" → "echo PROOF_REWRITE_WORKS_12345"
Expected behavior
The executed command should be echo PROOF_REWRITE_WORKS_12345, producing the output PROOF_REWRITE_WORKS_12345. The mutation in the hook should propagate to execute(b, ...).
Actual behavior
ls /tmp runs normally (real directory listing). The mutation visible inside the hook does not reach the tool execution layer.
Hypothesis
Between trigger("tool.execute.before", { ... }, { args: b }) and execute(b, ctx), the runtime appears to clone or re-derive the args object that execute receives — so mutations on the output.args reference passed to the hook never reach b. Likely candidates:
- Effect Schema decode of
bproduces a fresh object, and that decoded object is whatexecutereads while the hook sees the pre-decode reference. - A
structuredClone/Object.freezesomewhere in the trigger or registry path.
This contrasts with sibling hooks like chat.params, shell.env, and tool.execute.after where mutation reportedly does propagate — making tool.execute.before inconsistent with the rest of the hook surface.
Impact
Any plugin that needs to rewrite tool arguments before execution is broken in 1.18.x. A concrete casualty is the rtk OpenCode plugin, which rewrites shell commands (ls → rtk ls) to cache reads and cut tokens — its rewrite is silently ignored.
Additional context
- The hook does fire — confirmed by log output. The problem is specifically that mutations on
output.argsare discarded. - The
output.argsobject appears to be a separate reference from whatexecuteconsumes; writing tooutput.args.commandupdates the hook-visible value but not the executed one. - Tested with a hardcoded replacement (not just
rtk rewrite) to rule out anyrtk-specific issue — same result.
Activity
github-actions commentedon Jul 30, 2026
This issue might be a duplicate of existing issues. Please check:
tool.execute.beforehook arg mutation has no effect #31680: Same root cause —tool.execute.beforehook arg mutations have no effect because the originalargsvariable is used for execution instead of reading back from the hook outputoutput.argsmutations intool.execute.beforenot propagated to tool execution (closed)wjiuxing commentedon Jul 30, 2026
Closing this — my investigation had a critical flaw.
The
PROOF_REWRITE_WORKS_12345test was invalid: I modified the plugin file but did not restart OpenCode before running the test. The running instance still had the previous plugin version, which usesrtk rewrite(a transparent proxy). Sincertk ls /tmpproduces identical output tols /tmp, I incorrectly concluded mutation doesn't work.After re-checking the rtk history database, I found that
tool.execute.beforemutation does work correctly — today's records showls -la /tmp→rtk ls -la /tmpwith actual token savings. Theoutput.argsmutation propagates to tool execution as documented.The actual root cause of rtk stopping was unrelated: OpenCode 1.18.x changed global plugin auto-discovery (no longer scans
~/.config/opencode/plugins/*.ts), which prevented the rtk plugin from loading. Fixed by explicitfile://registration.Apologies for the noise. The mutation API works as designed.