tool.execute.after hook: UI renders metadata.output instead of output, ignoring mutations #9315

Open
opened 2026-02-16 18:12:10 -05:00 by yindo · 1 comment
Owner

Originally created by @isanchez31 on GitHub (Feb 14, 2026).

Originally assigned to: @rekram1-node on GitHub.

Description

The tool.execute.after plugin hook can mutate output.output on the result object, and the mutation correctly propagates to Session.updatePart({ state: { output: value.output.output } }). However, the TUI renders metadata.output instead of state.output for bash tools, so the mutation is never visible to the user.

In packages/opencode/src/cli/cmd/tui/routes/session/index.tsx, the Bash component reads:

const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))

But the bash tool sets metadata.output as a separate string copy at execution time, before the hook runs:

// packages/opencode/src/tool/bash.ts
return {
  title: params.description,
  metadata: {
    output: output, // ← snapshot before hook
    exit: proc.exitCode,
  },
  output, // ← this gets mutated by the hook, but UI doesn't read it
}

So even though the hook successfully mutates result.output, the UI never shows the change because it reads metadata.output which still has the pre-hook value.

Plugins

opencode-sandbox@0.1.1

OpenCode version

1.2.1

Steps to reproduce

  1. Create a plugin that appends text to output.output in tool.execute.after:
"tool.execute.after": async (input, output) => {
  if (input.tool !== "bash") return
  const text = output.output ?? ""
  if (text.includes("Operation not permitted")) {
    output.output = text + "\n\n⚠️ Annotation from plugin"
  }
}
  1. Run a bash command that produces "Operation not permitted" in its output
  2. Observe that the annotation does not appear in the TUI output panel

Expected: The annotation appears in the displayed output.

Actual: The TUI renders metadata.output (pre-hook snapshot) instead of state.output (post-hook mutated value).

Screenshot and/or share link

No response

Operating System

Ubuntu 24.04 (Linux 6.8.0-100-generic)

Terminal

Ghostty

Originally created by @isanchez31 on GitHub (Feb 14, 2026). Originally assigned to: @rekram1-node on GitHub. ### Description The `tool.execute.after` plugin hook can mutate `output.output` on the result object, and the mutation correctly propagates to `Session.updatePart({ state: { output: value.output.output } })`. However, the TUI renders `metadata.output` instead of `state.output` for bash tools, so the mutation is never visible to the user. In `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx`, the Bash component reads: ```typescript const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? "")) ``` But the bash tool sets `metadata.output` as a separate string copy at execution time, before the hook runs: ```typescript // packages/opencode/src/tool/bash.ts return { title: params.description, metadata: { output: output, // ← snapshot before hook exit: proc.exitCode, }, output, // ← this gets mutated by the hook, but UI doesn't read it } ``` So even though the hook successfully mutates `result.output`, the UI never shows the change because it reads `metadata.output` which still has the pre-hook value. ### Plugins opencode-sandbox@0.1.1 ### OpenCode version 1.2.1 ### Steps to reproduce 1. Create a plugin that appends text to `output.output` in `tool.execute.after`: ```typescript "tool.execute.after": async (input, output) => { if (input.tool !== "bash") return const text = output.output ?? "" if (text.includes("Operation not permitted")) { output.output = text + "\n\n⚠️ Annotation from plugin" } } ``` 2. Run a bash command that produces "Operation not permitted" in its output 3. Observe that the annotation does not appear in the TUI output panel **Expected:** The annotation appears in the displayed output. **Actual:** The TUI renders `metadata.output` (pre-hook snapshot) instead of `state.output` (post-hook mutated value). ### Screenshot and/or share link _No response_ ### Operating System Ubuntu 24.04 (Linux 6.8.0-100-generic) ### Terminal Ghostty
yindo added the opentui label 2026-02-16 18:12:10 -05:00
Author
Owner

@isanchez31 commented on GitHub (Feb 14, 2026):

Local Validation

I patched the installed plugin to log diagnostic data after the tool.execute.after hook runs. Here's the sanitized output from two consecutive blocked bash commands (curl google.com):

=== SANDBOX DEBUG 2026-02-14T10:09:52.286Z ===
tool: bash
mutated: true
output.output (first 200): bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted

⚠️ [opencode-sandbox] Command blocked or partially blocked by sandbox restrictions. Adjust config in .opencode/sandbox.json or OPENCODE_S
output.metadata keys: ["output","exit","description","truncated"]
output.metadata.output (first 200): bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted

metaBefore === output.metadata.output: true
output.output === output.metadata.output: false
=== END ===

Key findings

Field Value
mutated true — the hook successfully modified output.output
output.output Contains the appended ⚠️ [opencode-sandbox] ... annotation
output.metadata.output Only contains the original bwrap: ... Operation not permittedno annotation
metaBefore === output.metadata.output truemetadata.output was never changed by the hook
output.output === output.metadata.output false — they diverge after mutation

Debug patch used

"tool.execute.after": async (input, output) => {
  if (input.tool !== "bash") return;
  const text = output.output ?? "";
  const metaBefore = output.metadata?.output;
  if (text.includes("Operation not permitted") || text.includes("Connection blocked by network allowlist")) {
    output.output = text + "\n\n⚠️ ...annotation...";
  }
  // Diagnostic logging
  const fsSync = require("fs");
  fsSync.appendFileSync("/tmp/sandbox-debug.log", [
    "mutated: " + (output.output !== text),
    "output.output === output.metadata.output: " + (output.output === output.metadata?.output),
    "metaBefore === output.metadata.output: " + (metaBefore === output.metadata?.output),
  ].join("\n"));
}

Conclusion

This confirms that:

  1. The hook does mutate output.output correctly
  2. output.metadata.output is a separate string reference assigned before the hook runs
  3. Since JS strings are immutable, reassigning output.output creates a new reference without affecting metadata.output
  4. The TUI reads metadata.output → the annotation is never displayed

Environment: OpenCode 1.2.1, Linux (bwrap sandbox), opencode-sandbox@0.1.1

@isanchez31 commented on GitHub (Feb 14, 2026): ## Local Validation I patched the installed plugin to log diagnostic data after the `tool.execute.after` hook runs. Here's the sanitized output from two consecutive blocked bash commands (`curl google.com`): ``` === SANDBOX DEBUG 2026-02-14T10:09:52.286Z === tool: bash mutated: true output.output (first 200): bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted ⚠️ [opencode-sandbox] Command blocked or partially blocked by sandbox restrictions. Adjust config in .opencode/sandbox.json or OPENCODE_S output.metadata keys: ["output","exit","description","truncated"] output.metadata.output (first 200): bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted metaBefore === output.metadata.output: true output.output === output.metadata.output: false === END === ``` ### Key findings | Field | Value | |-------|-------| | `mutated` | `true` — the hook successfully modified `output.output` | | `output.output` | Contains the appended `⚠️ [opencode-sandbox] ...` annotation | | `output.metadata.output` | Only contains the original `bwrap: ... Operation not permitted` — **no annotation** | | `metaBefore === output.metadata.output` | `true` — `metadata.output` was never changed by the hook | | `output.output === output.metadata.output` | **`false`** — they diverge after mutation | ### Debug patch used ```javascript "tool.execute.after": async (input, output) => { if (input.tool !== "bash") return; const text = output.output ?? ""; const metaBefore = output.metadata?.output; if (text.includes("Operation not permitted") || text.includes("Connection blocked by network allowlist")) { output.output = text + "\n\n⚠️ ...annotation..."; } // Diagnostic logging const fsSync = require("fs"); fsSync.appendFileSync("/tmp/sandbox-debug.log", [ "mutated: " + (output.output !== text), "output.output === output.metadata.output: " + (output.output === output.metadata?.output), "metaBefore === output.metadata.output: " + (metaBefore === output.metadata?.output), ].join("\n")); } ``` ### Conclusion This confirms that: 1. The hook **does** mutate `output.output` correctly 2. `output.metadata.output` is a separate string reference assigned before the hook runs 3. Since JS strings are immutable, reassigning `output.output` creates a new reference without affecting `metadata.output` 4. The TUI reads `metadata.output` → the annotation is never displayed **Environment:** OpenCode 1.2.1, Linux (bwrap sandbox), opencode-sandbox@0.1.1
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: anomalyco/opencode#9315