Skip to content

tool.execute.before hook: mutations to output.args do not propagate to tool execution #39674

Closed

Description

@wjiuxing

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, where input carries read-only context, while output is 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.args

Based 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 b produces a fresh object, and that decoded object is what execute reads while the hook sees the pre-decode reference.
  • A structuredClone / Object.freeze somewhere 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.args are discarded.
  • The output.args object appears to be a separate reference from what execute consumes; writing to output.args.command updates the hook-visible value but not the executed one.
  • Tested with a hardcoded replacement (not just rtk rewrite) to rule out any rtk-specific issue — same result.

Activity

  1. github-actions commented on Jul 30, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

  2. wjiuxing commented on Jul 30, 2026

    @wjiuxing
    Author

    Closing this — my investigation had a critical flaw.

    The PROOF_REWRITE_WORKS_12345 test 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 uses rtk rewrite (a transparent proxy). Since rtk ls /tmp produces identical output to ls /tmp, I incorrectly concluded mutation doesn't work.

    After re-checking the rtk history database, I found that tool.execute.before mutation does work correctly — today's records show ls -la /tmp → rtk ls -la /tmp with actual token savings. The output.args mutation 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 explicit file:// registration.

    Apologies for the noise. The mutation API works as designed.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions