[BUG] `!` preprocessing in slash-command bodies fails in TUI but works in `claude -p` (with `model:` + `context: fork`)

Resolved 💬 1 comment Opened May 6, 2026 by ErezRazor Closed Jun 3, 2026

[BUG] ! preprocessing in slash-command bodies fails when invoked from TUI but works in claude -p (with model: + context: fork)

Claude Code version: 2.1.131
Platform: Linux (Ubuntu 20.04, x86_64)
Install method: native (~/.local/bin/claude~/.local/share/claude/versions/2.1.131)

Summary

A plugin slash command whose body contains an ` !bash <script> line and whose frontmatter declares model: <model> + context: fork behaves **differently in interactive TUI vs claude -p` headless mode**:

  • Headless (claude -p "/<plugin>:<cmd>"): the ! line is preprocessed before the LLM is invoked, the script's stdout replaces the placeholder, the forked Haiku agent receives the rendered body and echoes it verbatim. Marker script runs once (preprocessing).
  • TUI (typing /<plugin>:<cmd> interactively): the LLM in the forked context appears to receive the body without the ! having been preprocessed (or the inlined output gets lost). Haiku then improvises a Bash tool call to fetch the data itself, producing wrong output and ~20× the expected latency.

The docs explicitly state:

Each ` !<command> ` executes immediately (before Claude sees anything). The output replaces the placeholder in the skill content. Claude receives the fully-rendered prompt with actual PR data. This is preprocessing, not something Claude executes. Claude only sees the final result.

There's no documented difference in ! preprocessing semantics between TUI and -p mode, but the empirical behavior diverges.

Minimal reproducer

A 3-file plugin reproduces the divergence reliably. Files included verbatim below.

test-plugin/.claude-plugin/plugin.json

{
  "name": "fork-bash-bug",
  "version": "0.0.1",
  "description": "MWE for ! preprocessing in TUI vs headless"
}

test-plugin/scripts/marker.sh

#!/usr/bin/env bash
# Logs each invocation with timestamp + parent process info, then echoes
# a unique deterministic marker. Lets us count how many times the script
# ran and from where (preprocessing vs LLM tool call).
LOG="/tmp/cc-fork-bash-bug/marker-invocations.log"
TS=$(date +%s%N)
PARENT="$(ps -o ppid= -p $$ | tr -d ' ' )"
PARENT_CMD="$(ps -o args= -p "$PARENT" 2>/dev/null | head -c 120)"
{
  printf 'INVOCATION ts=%s pid=%s ppid=%s parent_cmd=%q\n' \
    "$TS" "$$" "$PARENT" "$PARENT_CMD"
} >> "$LOG"
echo "MARKER_PREPROCESSED_AT_$TS"

(make executable: chmod +x scripts/marker.sh)

test-plugin/commands/repro.md

````markdown
---
name: repro
description: MWE for slash-command ! preprocessing in TUI vs headless
argument-hint: ""
allowed-tools: Bash(bash ${CLAUDE_PLUGIN_ROOT}/scripts/marker.sh:*)
model: haiku
context: fork
effort: low
---

Repro

!bash ${CLAUDE_PLUGIN_ROOT}/scripts/marker.sh

Your only task

Output the line above (the one starting with MARKER_PREPROCESSED_AT_) verbatim as your response.
Do NOT call any tool. Do NOT add any prose. The marker IS the entire answer.

If you do not see a MARKER_PREPROCESSED_AT_ line above, output exactly:
PREPROCESSING_FAILED

Do not invoke Bash, Read, or any other tool under any circumstance — just echo the appropriate string.

````

marketplace.json (one level up, in .claude-plugin/)

{
  "name": "local-mwe",
  "owner": {"name": "mwe"},
  "plugins": [
    {"name": "fork-bash-bug", "source": "./test-plugin", "description": "MWE", "version": "0.0.1"}
  ]
}

Reproduction steps

In a fresh sandbox:

mkdir -p /tmp/cc-fork-bash-bug && cd /tmp/cc-fork-bash-bug
# (place the four files above)
chmod +x test-plugin/scripts/marker.sh

export HOME=/tmp/cc-fork-bash-bug/sandbox
mkdir -p $HOME/.claude
cp ~/.claude.json $HOME/.claude.json                    # bring auth
cp ~/.claude/.credentials.json $HOME/.claude/.credentials.json

claude plugin marketplace add /tmp/cc-fork-bash-bug
claude plugin install fork-bash-bug

Test 1 — Headless (works correctly)

rm -f /tmp/cc-fork-bash-bug/marker-invocations.log
claude -p "/fork-bash-bug:repro"
# → MARKER_PREPROCESSED_AT_1778102412825463446
cat /tmp/cc-fork-bash-bug/marker-invocations.log

Resulting marker-invocations.log after a successful headless run (exactly 1 line — the preprocessing invocation, parent is the shell-snapshot bash):

INVOCATION ts=1778102412825463446 pid=3275667 ppid=3275665 parent_cmd=/bin/bash\ -c\ source\ /tmp/cc-fork-bash-bug/sandbox/.claude/shell-snapshots/snapshot-bash-1778102412749-iz9woe.sh\ 2\>/dev/n

The timestamp in the response (MARKER_PREPROCESSED_AT_1778102412825463446) matches the ts=… in the log byte-for-byte, confirming the LLM relayed the preprocessed marker rather than running the script itself.

Test 2 — TUI (broken)

rm -f /tmp/cc-fork-bash-bug/marker-invocations.log
claude --debug 2>tui-debug.log
# Inside TUI, type:   /fork-bash-bug:repro    [Enter]
# Wait for response, then exit (Ctrl+C twice, or /quit).

Expected: response is the marker (e.g. MARKER_PREPROCESSED_AT_1778…), and marker-invocations.log contains exactly 1 line.

Actual:

  • The forked agent emits a Bash tool call, taking ~20s
  • marker-invocations.log typically shows 0 lines (preprocessing didn't fire) or 2 lines (preprocessing fired AND the LLM still made its own call)
  • Response is not the marker — it's prose ("I'll help you…") and/or output from a tool call the LLM improvised

The original report on which this MWE is based showed the LLM running bash playground/run.sh -p "<other-slash-command>" 2>&1 as a Bash tool call — i.e. spawning a nested claude -p to re-invoke the slash command, presumably because the ! content wasn't visible in its prompt.

Debug-log evidence (from the actual user-facing repro)

Snippet from ~/.claude/...debug.log for an interactive TUI invocation of the same shape (a real slash command /nudge:config with the same frontmatter shape as the MWE):

20:59:52.010 [DEBUG] cc_version=2.1.131.186
21:00:15.325 [DEBUG] Executing forked slash command /nudge:config with agent general-purpose
21:00:15.330 [DEBUG] Tool search disabled for model 'claude-haiku-4-5-20251001'
21:00:17.719 [INFO]  [Stall] tool_dispatch_start tool=Bash toolUseId=toolu_01Gnf1F…
21:00:38.334 [INFO]  [Stall] tool_dispatch_end   tool=Bash toolUseId=toolu_01Gnf1F… durationMs=20615
21:00:41.766 [DEBUG] Forked slash command /nudge:config completed

The tool_dispatch_start for Bash 2.4 seconds after the forked command launched, and the 20-second durationMs, indicate the LLM made its own Bash call. If ! preprocessing had inlined the script's stdout into the prompt, no such tool call should have occurred — the same shape under claude -p finishes in <2 seconds with a single preprocessing invocation and no Bash tool dispatch.

Workarounds tried / rejected

  • Per-command subagent (Task tool dispatch) — restoring an agents/<name>.md "passthrough relay" pattern bypasses ! preprocessing entirely (the agent's own bash call replaces it). This works in both TUI and -p but adds ~25 lines of per-command boilerplate per slash command. It's the pattern several large plugin repos have moved to (e.g. PluginMart admin commands).
  • -p "/...": — works correctly but doesn't help interactive users.

Hypotheses

  1. TUI-specific permission gate: allowed-tools: Bash(bash ${CLAUDE_PLUGIN_ROOT}/scripts/marker.sh:*) may be evaluated more strictly in TUI than in -p, causing the preprocessing bash call to be silently denied (the ! line is then substituted with empty/error and Haiku improvises). The --debug log shows permissionDecisionMs=7 for the LLM's own Bash call but no equivalent line for the preprocessing call — possibly because preprocessing is a different code path that doesn't log permission decisions.
  2. TUI uses a different prompt-rendering path that doesn't inline ! blocks for forked commands.
  3. Race condition between context: fork agent launch and ! preprocessing in TUI.

Environment

  • claude --version: 2.1.131 (Claude Code)
  • cc_version (from debug header): 2.1.131.186 and 2.1.131.9d9 observed in same session
  • Linux 5.15.0, x86_64, native install at ~/.local/bin/claude
  • DISABLE_AUTOUPDATER=1 set in the sandbox; reproducer also occurs without it on a real install

---

Tagging: slash-commands, plugins, skills, context: fork, ! prefix, dynamic context injection

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗