One of my Claude Code seats got stuck, so my rescue routine did what I told it to: it typed cl --resume <session-id> into the pane to restart the thing. Except Claude was still running in that pane. So instead of restarting anything, my resume command landed in the chat box — as a message to the agent — and promptly slammed into the weekly rate limit.
Quick translation for anyone who isn’t knee-deep in this. I run a bunch of AI coding agents (Claude Code) in the background. Each one lives in a tmux pane — think of tmux as a bunch of terminal windows stacked invisibly on a server, so a script can reach in and “type” into any of them. When a seat gets walled — out of its usage budget — I want a helper to reach in and gently restart it. The restart command is claude --resume. The bug: I sent that command to a pane that was not sitting at a command prompt. It was still inside the live agent. So my “restart” got read as me talking to the agent.
Here’s the wrong assumption I’d baked in. My check was basically “is there a claude process alive in this pane?” There was. pgrep found it. I took that as “the agent is fine, I don’t need to do anything” — or in the failing path, “it’s alive so I’ll just resume it.” Both readings share the same mistake.
A process being alive is not the same as being healthy.
The seat was very much alive. It was also walled and frozen — sitting there doing nothing, holding the pane, refusing to accept a real command because from its point of view I was just sending it more chat. Liveness told me nothing about health. That’s the whole trap in one line.
The fix is exit-first. Before I inject anything, I make sure the pane is actually a shell, not an agent:
- Send Ctrl-C twice to break out of the running agent.
- Confirm
pane_current_commandis my shell, notclaude. - Confirm
pgrep -P <pane_pid> -f claudereturns nothing — no claude child left. - Then paste the resume command.
Better still, don’t wrestle the pane at all — kill and respawn it clean.
The exit-first recovery, and why respawn-pane -k is safer give me the detail
The naive version just fires the command in:
# WRONG: assumes the pane is at a shell
tmux send-keys -t "$pane" "cl --resume $sid" EnterGuarded version — verify you’re at a shell first:
pane_pid=$(tmux display -p -t "$pane" '#{pane_pid}')
# break out of the TUI agent
tmux send-keys -t "$pane" C-c
sleep 0.3
tmux send-keys -t "$pane" C-c
sleep 0.3
cur=$(tmux display -p -t "$pane" '#{pane_current_command}')
if [[ "$cur" == "zsh" || "$cur" == "bash" ]] && \
! pgrep -P "$pane_pid" -f claude >/dev/null; then
tmux send-keys -t "$pane" "cl --resume $sid" Enter
else
echo "pane $pane not at shell (cur=$cur); skipping inject" >&2
fiEven cleaner: don’t rely on the pane’s state at all. Kill the process and respawn the pane, then resume into a guaranteed-fresh shell:
tmux respawn-pane -k -t "$pane" \
"cl --resume $sid"respawn-pane -k kills whatever’s running and starts your command in the pane’s own shell context — no Ctrl-C dance, no chance of typing into a live chat. pane_current_command and pgrep -P are your two independent liveness and location checks; use both, because either alone lies.
The general rule I’d hand anyone scripting recovery for an interactive CLI agent — Claude Code, aider, whatever sits in a TUI: never send-keys a command until you’ve proven the pane is a shell, not the agent. And stop treating a running PID as a working agent. Check where the cursor really is. When in doubt, kill it and respawn clean — a fresh shell can’t misread your rescue as chatter.