Claude Code in tmux: send-keys sieht keinen Permission-Prompt, claude agents --json schon
Ein tmux-Pane ist ein guter Ort, um einen Claude-Code-Agenten zu hosten, und eine schlechte Schnittstelle, um ihn zu steuern. In meinem Test verschwand eine Aufgabe, die ich in ein Pane mit offenem Permission-Prompt getippt hatte, und das Enter dahinter genehmigte den wartenden Befehl. Status, Wartegrund und das Ende eines Turns meldet Claude Code inzwischen selbst. Der Bildschirm darf also wieder einfach ein Bildschirm sein.
Inhalt
Der übliche Weg, mehrere Claude-Code-Agenten zu betreiben: eine tmux-Session pro Agent und ein Skript, das mit ihnen redet. tmux send-keys tippt eine Aufgabe, tmux capture-pane liest ab, ob sie fertig ist. So laufen meine Agenten schon den größten Teil dieses Jahres, und der Send-Helper dahinter ist durch so viele Commits geflickt worden, dass sich sein Git-Log liest wie eine Liste der Arten, auf die ein Terminal ein Skript anlügen kann.
In denselben Monaten hat Claude Code angefangen, genau das zu melden, was dieser Helper erraten musste. claude agents --json listet jede laufende Session mit ihrem Status und, wenn sie wartet, worauf sie wartet. Sessions können sich gegenseitig Nachrichten schicken, ohne ineinander zu tippen. Hooks feuern, wenn ein Turn endet oder Claude um eine Erlaubnis bittet. Dieser Text ist eine Anleitung, die Entscheidungen vom Bildschirm wegzuholen, mit einem Experiment, das zeigt, was passiert, wenn der Bildschirm sie trifft. Die Tests unten liefen auf Claude Code 2.1.306 und tmux 3.4 unter Linux. Wo eine Passage nur auf der Doku beruht, steht das dabei.
Wie steuerst du Claude Code in tmux mit send-keys?
Du schickst den Text wörtlich, drückst Enter in einem eigenen Aufruf und sprichst das Pane über ein exaktes Target an. Jede dieser drei Regeln stammt aus einem Commit mit Datum.
# 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:' EnterDas = ist wichtiger, als es aussieht. tmux löst ein -t-Target zuerst über den exakten Namen auf, dann über ein Präfix, dann über ein Muster. -t api trifft also stillschweigend api-worker, wenn es keine Session namens api gibt. Im Juli hatte dieser Präfix-Treffer schon ein anderes meiner Tools erwischt, und der Helper bekam denselben Fix: Jedes Target ist als =name: verankert, und der Doppelpunkt am Ende sorgt dafür, dass auch Befehle auf Pane-Ebene wie capture-pane und send-keys es akzeptieren. Die zweite Regel kam im Februar: Claude Code las schnell aufeinanderfolgenden Text plus Enter als Zeilenumbruch im Eingabefeld statt als Absenden, also geht der Text mit -l raus und das Enter folgt nach einer Pause. An einem leeren, wartenden Eingabefeld auf 2.1.306 konnte ich das nicht reproduzieren: Eine kurze Zeile und ein Block mit 1.471 Zeichen wurden beide abgeschickt, obwohl das Enter direkt nach dem Text kam. Die Notizen des Helpers verzeichnen aber noch einen Fall aus diesem Monat: Ein Enter kam an, während Claude beschäftigt war oder kompaktierte, und wurde zum Zeilenumbruch. Zwei Fälle im Oktober, die ich zuerst darauf geschoben hatte, waren in Wahrheit Claudes ausgegrauter Vorschlag für den nächsten Prompt, der im Feld stand. Die Trennung bleibt also. Sie kostet 0,3 Sekunden.
Die dritte Regel ist subtiler. =api-worker: heißt „das Pane, das in dieser Session gerade aktiv ist“. Ein Capture kann also ein Pane lesen und das Enter in einem anderen landen, wenn jemand dazwischen das Pane wechselt. Löse die Pane-ID einmal auf und verwende sie für jeden Aufruf einer Operation:
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.'Was macht tmux send-keys mit einem Permission-Prompt von Claude Code?
Es beantwortet ihn. Ich habe Claude Code in einem detached Pane im manuellen Permission-Modus gestartet, es gebeten, ein Verzeichnislisting in eine Datei zu schreiben, gewartet, bis der Bash-Permission-Dialog offen war, und dann die nächste Aufgabe so geschickt, wie es ein naiver Orchestrator tun würde: Text, Pause, Enter.
Der getippte Text tauchte nirgends auf. Der Cursor des Dialogs steht standardmäßig auf 1. Yes, also genehmigte das Enter den ausstehenden Befehl, listing.txt wurde geschrieben, und die Session ging zurück auf idle. Die Aufgabe, die ich „geschickt“ hatte (die README zusammenfassen), war einfach weg, ohne Fehler auf irgendeiner Seite. Ein Skript, das Erfolg daran misst, dass die Session erst busy und dann idle wird, hätte eine zugestellte Aufgabe protokolliert.
Ein bereites Pane fälschlich als beschäftigt zu lesen, kostet eine Verzögerung. Ein beschäftigtes oder blockiertes Pane als bereit zu lesen, kostet eine Genehmigung, die niemand erteilt hat, oder eine Aufgabe, die in einen Dialog getippt wurde. Der Folder-Trust-Dialog ist noch schlimmer: Seine Standardoption ist No, exit, ein verirrtes Enter beendet dort Claude. Drück in einem Claude-Code-Pane nie Enter, solange du nicht bestätigt hast, dass es am Eingabefeld wartet.
Warum geht das Lesen des Claude-Code-Bildschirms mit capture-pane immer wieder kaputt?
Weil der Bildschirm die Oberfläche von Claude Code für Menschen ist, und die ändert sich, sobald sich das Design für Menschen ändert. Mein Pane-Classifier sortiert einen erfassten Bildschirm in ready, busy, dialog, nackte Shell oder unknown, und sein Header ist eine Liste datierter Brüche: ein Onboarding-Prompt im August, ein Platzhalter-Hinweis Ende September, ein Spinner, der im Oktober seinen Text „esc to interrupt“ verlor, ein Trust-Dialog, der seine Optionsnummern verlor, ein langes Session-Label, das auf 2.1.302 den oberen Rand des Eingabefelds füllte. Ein Fix im Oktober galt einem Pane in Handybreite, 34 Spalten, in dem das Eingabefeld so weit umbrach, dass der Helper nie Enter drückte.
Jeder dieser Fixes war ein korrekter Parser für den Bildschirm von gestern. Der Classifier entscheidet inzwischen bei jeder Mehrdeutigkeit gegen „ready“, weil die Aufrufer auf ready destruktiv handeln: ins Pane tippen, die Session neu starten, ihr Repository committen. Lieber die Verzögerung zu riskieren, ist die richtige Entscheidung, und ich lese sie als Eingeständnis, dass der Bildschirm die Frage „bereit oder nicht?“ nicht beantworten kann. Ein Status, den das Programm über sich selbst meldet, kann es.
Was meldet claude agents —json für eine Claude-Code-Session in tmux?
Laut der Doku zu Agent view gibt es jede laufende Session aus, auch interaktive, die du selbst in tmux gestartet hast, mit dem status busy, waiting oder idle und, wenn sie wartet, einem waitingFor-Grund. Das ist genau das Signal, das der Pane-Classifier aus erfasstem Text rekonstruiert hat. Ich habe die Test-Session mit einem Claude-Namen gestartet, den das JSON und @-Erwähnungen verwenden. Der Name der tmux-Session gilt nur für tmux, ich habe beide gleich benannt:
tmux new-session -d -s api-worker -c ~/src/demo-repo 'claude -n api-worker'Hier ist ihre Zeile, während der Permission-Dialog offen war, gekürzt und mit geändertem Pfad:
[
{
"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"
}
]Beim Abfragen im Sekundentakt zeigte sich waiting mit permission prompt innerhalb von zwei Sekunden nach dem Prompt, dann busy, dann idle innerhalb von zwei Sekunden nach der Genehmigung. Als weitere Wartegründe nennt die Doku input needed, sandbox request, worker request und dialog open. Ein Gate für einen Orchestrator passt in ein paar Zeilen, und es muss im Zweifel sperren (fail closed):
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")'Schick nur bei idle. absent ist nicht idle. Bei waiting gib den waitingFor-Grund an einen Menschen weiter, nicht an send-keys. Und behandle idle als Beobachtung, nicht als Lock: Eine Nachricht, eine geplante Aufgabe oder ein Mensch kann zwischen deiner Prüfung und deinem Enter einen Turn starten. Prüf direkt vor dem Tastendruck noch einmal. Diese Lücke schließt sich nie ganz, und sie ist das beste Argument, das ich für die nativen Wege weiter unten habe.
Drei Details übersieht man leicht. Auf derselben Seite steht, dass interaktive Sessions in anderen Terminals „erst erscheinen, wenn du sie in den Hintergrund schickst“. Dieser Satz bezieht sich aber auf den Agent-view-Bildschirm, nicht auf das JSON. Auf 2.1.306 listete das JSON meine interaktiven Sessions in tmux, jede mit kind: "interactive". Eine Session, die am Folder-Trust-Dialog hängt, erscheint gar nicht. „Keine Zeile“ kann also „startet und ist blockiert“ bedeuten, nicht „weg“. Und busy ist ein Lebenszeichen, kein Fortschrittssignal: Eine Session, die an einem hängenden Stream klemmt, stünde trotzdem auf busy. Genau über diesen Fehler habe ich in dem Watchdog-Artikel geschrieben. In meinem Setup nutzt der Reaper, der untätige Sessions beendet, seit Mai claude agents --json, um Claude-Prozesse zu finden. Eine Task-Session beendet er trotzdem nur, wenn jedes Pane bei zwei Captures im Abstand von ein paar Sekunden ready oder eine nackte Shell zeigt und eine halbe Minute lang nichts ausgegeben wurde.
Wie schickst du einer anderen Claude-Code-Session Arbeit ohne send-keys?
Lass die Sessions einander Nachrichten schicken. Cross-Session-Messaging ist ab v2.1.224 auf macOS und Linux standardmäßig aktiv (@-Erwähnungen brauchen v2.1.232, Idle-Hinweise v2.1.236 in beiden Sessions). Damit listet ein Claude deine lokalen Sessions mit ListAgents und stellt Text mit SendMessage über einen Socket pro Session zu, der die Maschine nie verlässt. Du fragst in normaler Sprache oder nennst das Ziel mit einer @-Erwähnung:
Let @api-worker know the schema migration finished and it can rebase on main.Das funktioniert nur, wenn der Orchestrator selbst eine Claude-Session ist: SendMessage ist ein Tool, das Claude aufruft, kein Befehl, den ein Skript ausführen kann. Ist deiner ein Skript, dann ist der native Weg eine frische claude --bg-Session, und für eine bestehende interaktive Session bleibt es send-keys, abgesichert durch den JSON-Status oben. Zwischen zwei Claudes hat die Zustellung eine Semantik, die send-keys nie hatte. Ein beschäftigter Empfänger liest die Nachricht zwischen zwei Tool-Aufrufen, ein laufendes Tool wird also nicht unterbrochen. Ein untätiger Empfänger startet damit einen neuen Turn. Der empfangende Claude erfährt, dass die Nachricht von einer anderen Session kommt und nicht von dir. Deshalb kann er keinen ausstehenden Permission-Prompt beantworten, und er ist angewiesen, nie Berechtigungen, CLAUDE.md oder andere Konfiguration zu ändern, nur weil eine andere Session darum bittet. Der Teil mit der Konfiguration ist nur eine Anweisung an das Modell, im Transport erzwingt ihn nichts. Und ein Absender kann über den notify_when_idle-Input von SendMessage einen einzigen Hinweis anfordern, sobald das Ziel das nächste Mal untätig wird, statt es abzufragen.
Über einen Default stolpert jeder, der Agenten mit abgeschalteten Permission-Prompts betreibt. Ohne crossSessionInbound-Setting hält ein Empfänger im bypassPermissions-Modus jede Nachricht von einer Session, die Prompts zeigt, hinter einem Genehmigungsdialog fest. Der Dialog läuft nach dialogExpiry ab (standardmäßig fünf Minuten) und verwirft die Nachricht. Eine nicht angehängte Hintergrund-Session hält den Dialog offen, bis sich jemand anhängt. Umgekehrt gilt es auch: Ein Empfänger mit Prompts hält Nachrichten von einem Absender fest, der Prompts umgeht. Headless-Worker mit claude -p können diesen Dialog überhaupt nicht anzeigen. Laut Doku gibst du ihnen dafür per --settings den Wert accept für crossSessionInbound mit, wenn sie Nachrichten unbeaufsichtigt annehmen sollen. Eine mit --bare gestartete Session bindet keinen Socket und taucht nie in der Liste auf.
Welche Claude-Code-Hooks sagen dir, dass ein Agent fertig ist oder dich braucht?
Stop feuert, wenn Claude einen Turn beendet, StopFailure feuert stattdessen, wenn der Turn mit einem API-Fehler endet, und Notification feuert mit einem notification_type wie permission_prompt oder idle_prompt. Zusammen verdrahtet geben sie dir ein Event-Log statt eines Bildschirms, den du beobachten musst. Speichere dieses Skript als ~/. und mach es mit chmod +x ausführbar. Es hängt pro Event eine Zeile an:
#!/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")Verdrahte es in ~/.claude/settings.json mit allen drei Events, oder gib dasselbe JSON für eine einzelne Session mit --settings mit:
{
"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 meinem Test schrieb ein claude -p-Lauf eine Stop-Zeile mit dem Text der finalen Antwort, und die interaktive Session in tmux schrieb eine Notification-Zeile vom Typ permission_prompt, während ihr Dialog offen war. Meine erste Version hat den Input durch jq geleitet, das auf dieser Maschine nicht installiert ist. Der Hook endete mit Exit-Code 127, und in einem -p-Lauf fand ich den Fehler nur im Log von --debug-file. Führ das Skript von Hand mit einem Beispiel-Event auf stdin aus, bevor du dem Log vertraust.
Das Timing spielt hier eine Rolle. Laut der Hooks-Referenz feuert permission_prompt erst, wenn der Prompt etwa sechs Sekunden lang ohne Tippen gewartet hat, und jeder Tastendruck schiebt sie auf. Für ein Signal genau in dem Moment, in dem Claude fragt, gibt es ein eigenes PermissionRequest-Event, das auch erlauben oder ablehnen kann (Deny-Regeln haben trotzdem Vorrang). Meine permission_prompt-Benachrichtigung kam innerhalb der zehn Sekunden, die ich gewartet habe, Doku und Test stimmen also überein.
Ein einfaches Stop ist auch kein Erfolgsbeweis. Es markiert das Ende einer Antwort, nicht das Ende der Arbeit: Sein Input enthält background_tasks und session_crons, damit ein Hook „fertig“ von „pausiert, bis Hintergrundarbeit die Session weckt“ unterscheiden kann, und die Arbeit selbst musst du trotzdem prüfen. Ein API-Fehler löst es nie aus, deshalb steht StopFailure in der Konfiguration, und ein Abbruch durch den Nutzer löst keins von beiden aus. Lies den finalen Text aus last_assistant_message, nicht aus der Transkript-Datei, die laut Doku asynchron geschrieben wird und hinterherhinken kann. Und idle_prompt kommt etwa 60 Sekunden, nachdem Claude fertig ist, und nur, wenn seitdem niemand getippt hat und kein Hintergrund-Agent mehr läuft. Es ist also ein Stups nach dem Motto „den hast du vergessen“, kein Completion-Event. Die Benachrichtigung, die du auf dem Handy willst, ist permission_prompt. Der Rest gehört in ein Log. Genau diese Trennung habe ich in weck mich für die Entscheidung, nicht für den Tastendruck vertreten.
Wann sollte ein Claude-Code-Agent mit —bg laufen statt in tmux?
Wenn niemand ihm beim Tippen zusehen muss. claude --bg startet die Session unter einem Supervisor, den Claude Code bei Bedarf hochfährt, und kehrt sofort mit einer kurzen ID zurück:
$ 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 sessionEine Hintergrund-Zeile in claude agents --json bekommt zusätzlich eine id und einen state (working, blocked, done, failed oder stopped). Die beiden Felder beantworten verschiedene Fragen: state beschreibt den Job, status und pid beschreiben den Prozess und erscheinen nur, solange der Prozess lebt. Meine ging in wenigen Sekunden von working auf done. Fertige Hintergrund-Sessions fallen aus dem Standard-Listing heraus, beobachte den Abschluss also mit claude agents --json --all. claude logs gibt den rohen Terminal-Stream aus, inklusive Cursor-Codes und Spinner-Frames. Das ist etwas für Augen, Skripte sollten das JSON lesen. Nach claude stop und claude rm hat sich der Supervisor, den mein --bg-Aufruf gestartet hatte, von selbst beendet.
Die Abwägungen sind dokumentiert, nicht von mir getestet. Bei einer fertigen oder untätigen Hintergrund-Session, die etwa eine Stunde lang nicht angehängt ist, stoppt der Supervisor den Prozess (die Konversation bleibt auf der Platte, mit Ctrl+T pinnst du sie, damit sie weiterläuft). Bevor sie Dateien bearbeitet, wechselt eine mit --bg gestartete Session in einen eigenen Git-Worktree unter .claude/worktrees/, außer sie sitzt schon in einem Worktree oder das Verzeichnis ist kein Git-Repository. Eine Session, die du mit ← oder /background in den Hintergrund geschickt hast, bearbeitet weiter dort, wo sie war. worktree.bgIsolation auf "none" schaltet den Wechsel ab. Agent view ist in Research Preview. Und --bg zusammen mit -p wird abgelehnt, ein geskripteter One-Shot bleibt also claude -p.
Wenn du tmux als Host behalten willst, richtet Claude Code es dir ein. Diesen Teil habe ich der Doku und claude --help entnommen, nicht getestet. claude -w feature-x --tmux legt einen Git-Worktree und dafür eine tmux-Session an (--tmux setzt --worktree voraus und nutzt iTerm2-Panes, wenn es kann, außer du übergibst --tmux=classic). Für Agent-Teams legt --teammate-mode tmux jeden Teammate in ein eigenes Pane. Teams brauchen CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=, --teammate-mode taucht in claude --help nicht auf, und geteilte Panes werden im Terminal von VS Code, in Windows Terminal und in Ghostty nicht unterstützt. Das Muster mit parallelen Worktrees ist dasselbe, das ich für Codex in Codex CLI und verwaltete Git-Worktrees verglichen habe.
Wofür ist tmux bei Claude Code noch gut?
Fürs Hosten und für die Augen eines Menschen. Ein Pane, an das du dich von jedem Terminal aus anhängen kannst, das einen SSH-Abbruch übersteht und bei dem dir jemand über die Schulter schauen kann, lohnt sich weiterhin, und claude attach ersetzt das für eine in tmux gestartete Session nicht. Geändert hat sich, dass das Pane nicht mehr der Steuerkanal sein muss. Entscheide anhand von claude agents --json, stelle mit SendMessage oder einer frischen claude --bg-Session zu, erfahre über Hooks, wann etwas fertig ist, und lass send-keys für den einen Fall, in dem sonst ein Mensch getippt hätte.
Mein eigenes Setup ist auf halbem Weg: Der Reaper findet Sessions im JSON und beurteilt sie trotzdem anhand des Bildschirms, der Send-Pfad liest den Bildschirm von vorne bis hinten, und vom Bildschirm kommen die Bugs immer wieder.
01 / Diskussion
Hier gibt es keine Kommentarspalte. Diskussionen laufen auf X.
02 / Weitere Artikel
Claude Code /auto-mode-setup: dem Auto-Mode-Classifier deine Umgebung beibringen und prüfen, welches Modell deine Aktionen wirklich bewertet
Das Setup entwirft eine Vertrauensliste aus deinem Repo und den letzten Befehlen deines Agenten, und dieser Entwurf ersetzt den mitgelieferten Environment-Text, statt ihn zu ergänzen. Lies ihn als Diff gegen die Defaults. Und prüf, welches Modell deine Aktionen bewertet, bevor du Zeit in die Wahl eines Modells steckst.
Claude Code hinter einem LLM-Gateway: Die Credential entscheidet, wer zahlt, und ohne sie bekommt der Proxy deinen claude.ai-Login
Zwölf Beta-Werte pro Request mit Abo-Login, zehn mit Gateway-Token, und das Paar, das verschwindet, sagt mehr als die Tabellen in der Doku. Was ein Gateway unverändert durchreichen muss, welcher Default still teurer wird und warum ein Pinning per allowedProviders Entwicklern auch ihren eigenen Debugging-Listener nimmt.
Claude Code Fork-Subagents in claude -p: was 2.1.302 für Headless-Runs geändert hat
Angeboten heißt nicht benutzt: Ein Modell im Headless-Modus wählt den Fork vielleicht nie, und die offizielle Doku beschreibt noch den alten Standard. Wie du prüfst, welchen Subagent deine Pipeline genutzt hat, warum ein Fork im Hintergrund einem Parser zwei Antworten liefert und wo sich das Abschalten lohnt.