Max Narditdata & ai systems
← Articles
10 mindev

Claude Code in tmux: send-keys can't see a permission prompt, claude agents --json can

A tmux pane is a fine place to host a Claude Code agent and a poor interface to drive one. In my test a task typed into a pane that sat on a permission prompt vanished, and its Enter approved the pending command. The status, the waiting reason and the finish event are now reported by Claude Code itself, so the screen can go back to being a screen.

Contents

The usual way to run several Claude Code agents is a tmux session per agent and a script that talks to them: tmux send-keys to type a task, tmux capture-pane to read whether it is done. I have run agents that way for most of this year, and the send helper behind it has been patched through so many commits that its git log reads like a list of ways a terminal can lie to a script.

Over the same months Claude Code started reporting the things that helper was guessing at. claude agents --json lists every live session with its status and, when it is waiting, what it is waiting for. Sessions can message each other without typing into each other. Hooks fire when a turn ends or Claude asks for permission. This is a howto for moving the decisions off the screen, with an experiment that shows what happens when the screen makes them. The tests below ran on Claude Code 2.1.306 and tmux 3.4 on Linux; where a passage rests on the docs alone, it says so.

How do you drive Claude Code in tmux with send-keys?

You send the text literally, press Enter as a separate call, and address the pane by an exact target. Each of the three came from a commit with a date on it.

shell
# exact session match, literal text, Enter as its own keystroke
tmux send-keys -t '=api-worker:' -l 'Run the test suite and fix the first failure.'
sleep 0.3
tmux send-keys -t '=api-worker:' Enter

The = matters more than it looks. tmux resolves a -t target by exact name, then by prefix, then by pattern, so -t api quietly hits api-worker when no session called api exists. In July that prefix match had already bitten another of my tools, and the helper got the same fix: every target anchored as =name:, where the trailing colon lets pane-level commands such as capture-pane and send-keys accept it too. The second rule came in February: Claude Code read a fast text-plus-Enter as a newline inside the input box instead of a submit, so the text goes with -l and the Enter follows after a pause. At an idle input box on 2.1.306 I could not reproduce it: a short line and a 1,471-character block both submitted with Enter sent straight after the text. The helper’s notes still record an Enter that landed while Claude was busy or compacting turning into a newline, seen this month, and two October cases first blamed on it turned out to be Claude’s dim next-prompt suggestion sitting in the box. So the split stays. It costs 0.3 seconds.

The third rule is subtler. =api-worker: means “whatever pane is active in that session right now”, so a capture can read one pane and the Enter land in another if someone switches panes in between. Resolve the pane id once and use it for every call of one operation:

shell
PANE=$(tmux display-message -p -t '=api-worker:' '#{pane_id}') || exit 1
[[ $PANE == %[0-9]* ]] || exit 1   # an empty target means "the current pane" to tmux
tmux capture-pane -p -t "$PANE" | tail -5
tmux send-keys -t "$PANE" -l 'Next task.'

What does tmux send-keys do to a Claude Code permission prompt?

It answers it. I started Claude Code in a detached pane in manual permission mode, asked it to write a directory listing to a file, waited until the Bash permission dialog was up, and then sent the next task the way a naive orchestrator would: text, pause, Enter.

The typed text never appeared anywhere. The dialog’s cursor sits on 1. Yes by default, so the Enter approved the pending command, listing.txt was written, and the session went back to idle. The task I had “sent” (summarise the README) was simply gone, with no error on either side. A script that checks for success by watching the session go busy and then idle would have logged a delivered task.

Reading a ready pane as busy costs a delay. Reading a busy or blocked pane as ready costs an approval nobody gave, or a task typed into a dialog. The folder trust dialog is worse: its default option is No, exit, so a stray Enter there quits Claude. Never press Enter in a Claude Code pane unless you have confirmed it is at its input box.

Why does reading the Claude Code screen with capture-pane keep breaking?

Because the screen is Claude Code’s interface for people, and it changes whenever the people-facing design does. My pane classifier sorts a captured screen into ready, busy, dialog, bare shell or unknown, and its header is a list of dated breakages: an onboarding prompt in August, a placeholder hint in late September, a spinner that dropped its “esc to interrupt” text in October, a trust dialog that lost its option numbers, a long session label that filled the box’s top border on 2.1.302. One October fix was for a phone-width pane, 34 columns, where the input box wrapped so far that the helper never pressed Enter at all.

Every one of those was a correct parser of yesterday’s screen. The classifier now resolves every ambiguity away from “ready”, because the callers act destructively on ready: type into the pane, restart the session, commit its repository. Leaning toward the delay is the right call, and I read it as an admission that the screen cannot answer the question. A status the program reports about itself can.

What does claude agents —json report for a Claude Code session in tmux?

Per the Agent view docs it prints every live session, including interactive ones you started yourself in tmux, with a status of busy, waiting or idle and, when waiting, a waitingFor reason. That is the signal the pane classifier was reconstructing from captured text. I started the test session with a Claude name, which is what the JSON and @ mentions use; the tmux session name is only for tmux, and I made them match:

shell
tmux new-session -d -s api-worker -c ~/src/demo-repo 'claude -n api-worker'

Here is its row while the permission dialog was open, trimmed, with the path changed:

json
[
  {
    "pid": 3922645,
    "cwd": "/home/me/src/demo-repo",
    "kind": "interactive",
    "startedAt": 1792553808437,
    "sessionId": "d3b95339-dde6-4641-8a03-211db4633a12",
    "name": "api-worker",
    "status": "waiting",
    "waitingFor": "permission prompt"
  }
]

Polling once a second showed waiting with permission prompt within two seconds of the prompt, then busy, then idle within two seconds of the approval. The docs list the other waiting reasons as input needed, sandbox request, worker request and dialog open. A gate for an orchestrator fits in a few lines, and it has to fail closed:

shell
claude agents --json | python3 -c '
import json, sys
rows = [s for s in json.load(sys.stdin) if s.get("name") == "api-worker"]
print(rows[0].get("status", "unknown") if len(rows) == 1 else "absent" if not rows else "ambiguous")'

Send only on idle; absent is not idle. On waiting, route the waitingFor reason to a person rather than to send-keys. And treat idle as an observation, not a lock: a message, a scheduled task or a person can start a turn between your check and your Enter. Re-check right before the keystroke. That gap never closes completely, and it is the best argument I have for the native paths below.

Three details are easy to miss. The same page says interactive sessions in other terminals “don’t appear until you background them”, but that sentence is about the agent view screen, not the JSON; on 2.1.306 the JSON listed my interactive sessions in tmux, each with kind: "interactive". A session sitting on the folder trust dialog does not appear at all, so “no row” can mean “starting and blocked”, not “gone”. And busy is a presence signal, not a progress signal: a session wedged on a stalled stream would still read busy, which is the failure I wrote about in the watchdog piece. In my setup the reaper that kills idle sessions has used claude agents --json to find Claude processes since May, and it still kills a task session only when every pane reads ready or a bare shell on two captures a few seconds apart and nothing has printed for half a minute.

How do you send work to another Claude Code session without send-keys?

Let the sessions message each other. Cross-session messaging, on by default from v2.1.224 on macOS and Linux (@ mentions need v2.1.232, idle notices v2.1.236 in both sessions), lets one Claude list your local sessions with ListAgents and deliver text with SendMessage over a per-session socket that never leaves the machine. You ask in plain language, or name the target with an @ mention:

Let @api-worker know the schema migration finished and it can rebase on main.

This only works when the orchestrator is itself a Claude session: SendMessage is a tool Claude calls, not a command a script can run. If yours is a script, the native path is a fresh claude --bg session, and for an existing interactive session it is still send-keys, gated on the JSON status above. For Claude-to-Claude, the delivery semantics are what send-keys never had. A busy receiver reads the message between tool calls, so a running tool is not interrupted; an idle one starts a new turn with it. The receiving Claude is told the message came from another session, not from you, so it cannot answer a pending permission prompt, and it is instructed never to change permissions, CLAUDE.md or other configuration because another session asked. The configuration part is only an instruction to the model; nothing in the transport enforces it. And a sender can ask for one notice when the target next goes idle, through the notify_when_idle input of SendMessage, instead of polling it.

One default will bite anyone who runs agents with permission prompts turned off. With no crossSessionInbound setting, a receiver in bypassPermissions mode holds every message from a session that prompts, behind an approval dialog that expires after dialogExpiry (five minutes by default) and drops the message; an unattached background session keeps the dialog open until someone attaches. The reverse holds too: a prompting receiver holds messages from a sender that bypasses prompts. Headless claude -p workers cannot show that dialog at all; the docs say to start them with crossSessionInbound set to accept in their --settings value if they should take messages unattended. A session started with --bare binds no socket and never appears in the list.

Which Claude Code hooks tell you that an agent finished or needs you?

Stop fires when Claude finishes a turn, StopFailure fires instead when the turn ends on an API error, and Notification fires with a notification_type such as permission_prompt or idle_prompt. Wired together, they give you an event log in place of a screen to watch. Save this script as ~/.claude/hooks/agent-event.py and chmod +x it; it appends one line per event:

python
#!/usr/bin/env python3
# Append one line per Claude Code lifecycle event to a log your orchestrator reads.
import json, os, sys, time
 
event = json.load(sys.stdin)
record = {
    "at": time.time(),
    "event": event.get("hook_event_name"),
    "session": event.get("session_id"),
    "cwd": event.get("cwd"),
    "type": event.get("notification_type") or event.get("error"),
    "last": (event.get("last_assistant_message") or "")[:200],
}
path = os.path.expanduser("~/.claude/agent-events.jsonl")
with open(path, "a") as f:
    f.write(json.dumps(record) + "\n")

Wire it to all three events in ~/.claude/settings.json, or pass the same JSON with --settings for one session:

json
{
  "hooks": {
    "Stop": [{ "hooks": [{ "type": "command", "command": "~/.claude/hooks/agent-event.py" }] }],
    "StopFailure": [{ "hooks": [{ "type": "command", "command": "~/.claude/hooks/agent-event.py" }] }],
    "Notification": [
      {
        "matcher": "permission_prompt|idle_prompt",
        "hooks": [{ "type": "command", "command": "~/.claude/hooks/agent-event.py" }]
      }
    ]
  }
}

In my test a claude -p run wrote a Stop line carrying the final reply text, and the interactive session in tmux wrote a Notification line of type permission_prompt while its dialog was open. My first version piped the input through jq, which this machine does not have; that hook exited 127, and in a -p run I found the error only in the --debug-file log. Run the script by hand with a sample event on stdin before trusting the log.

Timing matters here. Per the hooks reference, permission_prompt fires only after the prompt has waited about six seconds with no typing, and every keystroke defers it. For a signal at the moment Claude asks, there is a separate PermissionRequest event, which can also allow or deny (deny rules still win). My permission_prompt notification arrived within the ten seconds I waited, so the docs and the test agree.

A plain Stop is not proof of success either. It marks the end of a response, not of the job: its input carries background_tasks and session_crons so a hook can tell “done” from “paused until background work wakes it”, and the work itself still needs checking. An API error never fires it, which is why StopFailure is in the config, and a user interrupt fires neither. Read last_assistant_message for the final text rather than the transcript file, which the docs say is written asynchronously and can lag. And idle_prompt arrives about 60 seconds after Claude finishes, only if nobody typed since and no background agent is still running, so it is a “you forgot this one” nudge rather than a completion event. The notification you want on your phone is permission_prompt; the rest belongs in a log, which is the split I argued for in wake me for the decision, not the keystroke.

When should a Claude Code agent run with —bg instead of in tmux?

When nobody needs to watch it type. claude --bg starts the session under a supervisor that Claude Code launches on demand and returns at once with a short id:

$ claude --bg --name docs-check "Reply with the single word: done"
backgrounded · 4313e449 · docs-check
  claude agents             list sessions
  claude attach 4313e449    open in this terminal
  claude logs 4313e449      show recent output
  claude stop 4313e449      stop this session

A background row in claude agents --json adds an id and a state (working, blocked, done, failed or stopped). The two fields answer different questions: state is the job, status and pid are the process, and they appear only while the process is alive. Mine went from working to done in a few seconds. Finished background sessions drop out of the default listing, so watch for completion with claude agents --json --all. claude logs prints the raw terminal stream, cursor codes and spinner frames included, so it is for eyes, and scripts should read the JSON. After claude stop and claude rm the supervisor my --bg call had started exited by itself.

The trade-offs are documented, not tested here. A finished or idle background session left unattached for about an hour has its process stopped by the supervisor (the conversation stays on disk; pin it with Ctrl+T to keep it running). Before editing files, a session started with --bg moves into its own git worktree under .claude/worktrees/, unless it already sits in a worktree or the directory is not a git repository; a session you backgrounded with ← or /background keeps editing where it was. worktree.bgIsolation set to "none" turns the move off. Agent view is in research preview. And --bg with -p is rejected, so a scripted one-shot stays claude -p.

If you want to keep tmux as the host, Claude Code will set it up for you; I took this part from the docs and claude --help and did not test it. claude -w feature-x --tmux creates a git worktree and a tmux session for it (--tmux requires --worktree, and uses iTerm2 panes when it can unless you pass --tmux=classic). For agent teams, --teammate-mode tmux puts each teammate in its own pane; teams need CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1, --teammate-mode does not appear in claude --help, and split panes are not supported in VS Code’s terminal, Windows Terminal or Ghostty. The parallel-worktree pattern itself is the same one I compared for Codex in Codex CLI managed Git worktrees.

What is tmux still good for with Claude Code?

Hosting, and a human’s eyes. A pane you can attach to from any terminal, survive an SSH drop with, and read over someone’s shoulder is still worth having, and claude attach does not replace that for a session you started in tmux. What changed is that the pane no longer has to be the control channel. Decide from claude agents --json, deliver with SendMessage or a fresh claude --bg, learn about finishes from hooks, and leave send-keys for the one case where a person would have typed anyway.

My own setup is halfway there: the reaper finds sessions in the JSON and still judges them by the screen, the send path reads the screen end to end, and the screen is where the bugs keep coming from.

01 / Discussion

No comment section here — all discussions happen on X.

Max Nardit@mnardit

02 / More articles