[BUG] 2.1.224 strips TMUX_PANE from hook environment (regression from 2.1.223) — tmux-integrated hooks silently no-op

Status Closed — not planned
Reported on v2.1.224
Maintainer reply None cached
Activity 7 comments · opened Aug 7, 2026 · closed Aug 16, 2026

Environment: macOS (darwin 25.5.0), tmux 3.7b, Claude Code 2.1.224 (native binary)

Summary

Command hooks in 2.1.224 no longer receive TMUX_PANE. A session on the 2.1.223 binary, same machine and same settings.json, passes it through. Any hook targeting its own pane (tmux set-option -p -t "$TMUX_PANE" ...) silently no-ops — the hook exits 0, so nothing surfaces in transcripts or the UI, and the breakage looks session-specific rather than version-specific.

Repro

  1. Run Claude Code 2.1.224 inside a tmux pane.
  2. Register a minimal hook:

``json
{"hooks": {"UserPromptSubmit": [{"hooks": [{"type": "command",
"command": "bash -c 'echo \"${TMUX_PANE:-STRIPPED}\" >> /tmp/hook-env.log'"}]}]}}
``

  1. Submit a prompt.

Expected: the pane id (e.g. %41) — 2.1.223 behavior.
Actual on 2.1.224: STRIPPED.

Verified side-by-side on UserPromptSubmit and Stop: the 2.1.223 session logs its pane id, the 2.1.224 session logs the variable as unset. PATH survives; other variables not audited. The hook can still reach the tmux server via the default socket, so the loss is specifically pane identity.

Impact

Hooks driving per-pane tmux state (status-bar working indicators, pane attention flags, pane title cleanup) fail silently with no error trail.

Workaround

Walk the hook process's ancestry against tmux list-panes -a -F '#{pane_id} #{pane_pid}' — the hook descends from the pane's shell. Works, but every tmux-integrated hook author must know to do it.

If the sanitization is intentional, documenting the hook env contract (and passing pane identity in the hook JSON payload) would make it survivable.

View original on GitHub ↗

3 Comments

yurukusa · 23 days ago

I can't reproduce the regression itself — this machine is on 2.1.220 and TMUX_PANE still arrives — but I do run tmux-integrated hooks here, so I wrote the ancestry workaround you sketched and measured it. Posting it in case it saves someone the plumbing.

resolve_pane_id() {
    # 2.1.223 and earlier: the variable is there, use it.
    [ -n "${TMUX_PANE:-}" ] && { printf '%s' "$TMUX_PANE"; return 0; }

    command -v tmux >/dev/null 2>&1 || return 1
    local panes
    panes=$(tmux list-panes -a -F '#{pane_id} #{pane_pid}' 2>/dev/null) || return 1
    [ -z "$panes" ] && return 1

    local pid=$$ depth=0
    while [ "$pid" -gt 1 ] && [ "$depth" -lt 40 ]; do
        local hit
        hit=$(printf '%s\n' "$panes" | awk -v p="$pid" '$2 == p {print $1; exit}')
        [ -n "$hit" ] && { printf '%s' "$hit"; return 0; }
        if [ -r "/proc/$pid/stat" ]; then
            pid=$(awk '{print $4}' "/proc/$pid/stat" 2>/dev/null)   # Linux
        else
            pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')     # macOS
        fi
        [ -z "$pid" ] && return 1
        depth=$((depth + 1))
    done
    return 1
}

What I measured on this box (Linux, tmux 3.4, 2.1.220), with two panes alive (%0 and %5):

| | result |
|---|---|
| TMUX_PANE present | %0 |
| TMUX_PANE unset, resolved by ancestry | %0 |

The two-pane part matters. With a single pane any lookup "works" by accident, so a match there proves nothing. Here it picked the right one out of two.

Three things worth knowing if you adopt this:

  • /proc/<pid>/stat field 4 is PPID, and awk '{print $4}' is only safe because the comm field (field 2) is parenthesized and this process's name has no spaces. If you want to be strict, cut everything up to the last ) first. The ps -o ppid= branch covers macOS, where /proc doesn't exist.
  • list-panes -a is a server-wide list, so it also matches panes in other sessions. That is what you want here — a hook does not necessarily know its session name — but it means the lookup costs one tmux round trip per hook invocation.
  • The depth cap (40) is there because a hook that somehow isn't a descendant of any pane would otherwise walk to PID 1 on every call.

Caveat on my end: I unset the variable to simulate 2.1.224 rather than running it, and I could not verify that hook processes on 2.1.224 still descend from the pane's shell. If the sanitization also reparents the hook, ancestry won't find anything and this returns failure rather than a wrong pane — which is at least the safe direction.

On the documentation point in your last paragraph: a tmux_pane field in the hook JSON payload would remove the need for any of this, and it would also work for hooks that aren't spawned from the pane at all. The current situation is hard to debug precisely because the failure is silent — tmux set-option -p -t "" ... exits 0.

andrewroxby · 23 days ago

Can confirm your open caveat on real 2.1.224: hook processes do still descend from the pane's shell. I deployed the ancestry walk against an affected 2.1.224 session and it resolved the correct pane (out of six panes across two sessions), with the hook's pane-state write landing within a second of prompt submit. The observed chain is short — hook shell → claude → pane shell, two hops — so a small depth cap suffices, though 40 is harmless.

One sharpening of your last point: tmux set-option -p -t "" … doesn't just exit 0 — the empty target resolves to the current pane, so an unguarded call writes state onto whichever pane tmux considers current rather than silently doing nothing. That makes an [ -n "$pane" ] guard before every -t call essential even with the resolver in place, and strengthens the case for a tmux_pane field in the hook payload.

yurukusa · 22 days ago

Thanks — that closes the exact gap I flagged, and your correction makes my last paragraph wrong in a way worth stating plainly.

I wrote that the failure is silent because tmux set-option -p -t "" … exits 0. That's the benign reading. What you describe is worse: an empty -t resolves to the current pane, so the write lands on someone else's pane rather than nowhere. A status indicator that quietly appears on whichever pane you happened to be looking at is harder to trace back than one that never appears at all — the symptom shows up somewhere real, just detached from its cause.

So the guard is not optional decoration around the resolver, it's the part that prevents a wrong write:

pane=$(resolve_pane_id) || pane=""
[ -n "$pane" ] || return 0          # no target beats the wrong target
tmux set-option -p -t "$pane" @my-state busy

Two other things I'm taking from your run.

Two hops, not forty. You measured hook shell → claude → pane shell. My cap of 40 was a runaway guard chosen without knowing the real depth; now that the shape is known, anything past a handful of hops means the process isn't a pane descendant at all and the walk should give up rather than keep climbing. Same behaviour, but for a stated reason instead of an arbitrary number.

Six panes across two sessions is the test case that matters. For context on how weak my own check was: I ran the resolver on 2.1.220 (Linux/WSL2) with two panes in one session, confirmed the marker file received the correct pane id, and stopped there. Two panes is barely better than one — a resolver can pick the right pane out of two by luck often enough to look correct. Anyone else verifying this should do it with more panes alive than they think necessary, in more than one session, since list-panes -a spans the server.

The tmux_pane field in the hook payload would make all of this unnecessary, and your finding strengthens that ask: without it, every hook author has to know that an unguarded -t writes to the wrong pane, which is not something the current docs would lead anyone to.

Showing cached comments. Read the full discussion on GitHub ↗