[BUG] `!` preprocessing in slash-command bodies fails in TUI but works in `claude -p` (with `model:` + `context: fork`)
[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.logtypically 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-pbut 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
- 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--debuglog showspermissionDecisionMs=7for 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. - TUI uses a different prompt-rendering path that doesn't inline
!blocks for forked commands. - Race condition between
context: forkagent launch and!preprocessing in TUI.
Environment
claude --version:2.1.131 (Claude Code)cc_version(from debug header):2.1.131.186and2.1.131.9d9observed in same session- Linux 5.15.0, x86_64, native install at
~/.local/bin/claude DISABLE_AUTOUPDATER=1set in the sandbox; reproducer also occurs without it on a real install
---
Tagging: slash-commands, plugins, skills, context: fork, ! prefix, dynamic context injection
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗