Skip to content

v2: tool.execute.before rewrite executes but chat shows pre-hook input #51596

Open

Description

@Asura7170

Description

A server plugin that rewrites shell commands in tool.execute.before (mutating event.input.command, e.g. git status → rtk git status) executes the rewritten command — confirmed in server logs (spawning process ... "rtk git log --oneline -3") — but the chat keeps showing the original pre-hook command ($ git ... instead of $ rtk git ...). In v1 the rewritten command was shown.

Concrete case: the rtk wrapper plugin (https://github.com/rtk-ai/rtk — the v1→v2 format side is tracked at rtk-ai/rtk#3898), OpenCode v2.0.17, Windows 11, PowerShell 7 / Windows Terminal.

Steps to reproduce

  1. Install a server plugin that rewrites event.input.command in ctx.tool.hook("execute.before", ...) (repro: rtk wrapper, git status → rtk git status).
  2. Run any rewritten command, e.g. git status.
  3. The server executes rtk git status, but the chat shows $ git status.

Expected

The chat shows the command that actually ran ($ rtk git status), as in v1.

Prior art

Activity

  1. github-actions commented on Sep 27, 2026

    @github-actions
    Contributor

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

  2. jksaunders commented on Oct 8, 2026

    @jksaunders

    Adding macOS evidence and narrowing this to the shell tool (opencode v2.0.24, Darwin 25.6.0 arm64, /bin/zsh, latest channel).

    Same symptom: a plugin that mutates event.input.command in ctx.tool.hook("execute.before", ...) changes the command that actually runs, but the stored tool call keeps the pre-hook input. Reproduced with both:

    • the rtk wrapper plugin (git status → rtk git status), and
    • a project plugin that appends a marker comment (git status → git status # marker).

    What may be new: this looks specific to the shell tool, and it regressed relative to bash. In one session I inspected the stored messages via GET /api/session/{id}/context and checked state.input.command for each tool call:

    • bash tool calls: 19/19 stored inputs contained the injected marker.
    • shell tool calls: 0/53 stored inputs contained it.

    So under bash the execute.before mutation was written back to the message; under shell it is not. Server logs confirm the mutation still reaches execution:

    message="spawning process" command=/bin/zsh args=["-c","rtk git status # automatically rewritten ..."]
    

    while the same call's stored tool part is still git status.

    Since the bash tool persisted the mutation on the same 2.0.x server, this reads as a shell-tool regression rather than a general hook limitation.

    Repro:

    1. Register a plugin that appends a marker to event.input.command when event.tool === "shell".
    2. Run git status (or any command the rtk plugin rewrites).
    3. The server log shows the rewritten/tagged command, but the chat and the stored tool part show the original.
  3. opencodejr commented on Oct 28, 2026

    @opencodejr

    Thanks for the detailed report. I reproduced this on v2.0.17. A project plugin's execute.before hook rewrites the shell command to echo REWRITTEN-BY-HOOK && <command>. The simulated model runs echo original-command. The output shows REWRITTEN-BY-HOOK, so the rewritten command ran, but the chat header still shows $ echo original-command. The recording and screenshot below show it, and the script below reproduces it.

    I couldn't confirm it on the latest dev (86ce19d) because my test harness doesn't show tool calls on that build. As far as I can tell from the code, it still runs the post-hook input but shows the model's original input. Related: #37963 (same gap, seen from the model's side), #35882, #42409.

    recording.mp4

    Screenshot

    Reproduction script (drive.ts)
    // Repro for anomalyco/opencode#51596: a tool "execute.before" hook rewrites the shell
    // command; the rewritten command runs, but the TUI still shows the original command.
    import { Effect, Stream } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    const plugin = `export default {
      id: "rewrite-shell",
      setup: async (ctx) => {
        await ctx.tool.hook("execute.before", (event) => {
          const input = event.input as { command?: unknown }
          if (event.tool === "shell" && typeof input?.command === "string" && !input.command.startsWith("echo REWRITTEN")) {
            event.input = { ...input, command: "echo REWRITTEN-BY-HOOK && " + input.command }
          }
        })
      },
    }
    `
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true },
        config: { permission: { "*": "allow" } } as any,
        project: {
          git: true,
          files: { ".opencode/plugins/rewrite-shell.ts": plugin },
        },
      },
      ({ ui, llm }) =>
        Effect.gen(function* () {
          yield* llm.serve((_request, index) =>
            index === 0
              ? Stream.make(
                  Llm.toolCall({ index: 0, id: "call_shell", name: "shell", input: { command: "echo original-command" } }),
                  Llm.finish("tool-calls"),
                )
              : Stream.make(Llm.text("Done.")),
          )
          yield* ui.submit("Run echo original-command")
          yield* ui.waitFor("Done.", { timeout: 60000 } as any).pipe(Effect.catchTag("UiWaitTimeoutError", () => Effect.void))
          yield* Effect.sleep(1000)
          yield* ui.screenshot("rewritten-vs-displayed")
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions