Przejdź do głównej zawartości

tmux dla flot agentów: sesje, send-keys, wait-for

Każdy poradnik „tmux + agenty AI” napisany w ostatnim roku zaczyna się tak samo: pętla wołająca git worktree add, potem tmux new-window na każdy worktree, potem send-keys, żeby odpalić agenta. Ta rada jest dziś o jedną warstwę za nisko. Claude Code sam tworzy i sprząta swoje worktree przez --worktree, Cursor ma /worktree w oknie Agents, a Codex prowadzi wątki w worktree natywnie. Problem izolacji, który rozwiązywały skrypty tmuxowe, wchłonęły same agenty.

Do czego więc nadal jest tmux? Do trzech rzeczy, których żaden agent za ciebie nie zrobi: nie widzisz sześciu agentów naraz, klapa laptopa się zamyka, a agenty nie umieją ze sobą rozmawiać. To są problemy multipleksera terminala i tmux radzi sobie z nimi bardzo dobrze. Ten artykuł jest o tym szwie — nie o git worktree add w pętli.

  • Jasnego podziału zadań: co należy do agenta, co do tmuxa, a czego nie robi nikt
  • Skryptu bin/fleet, który rozdziela N zadań bez ręcznego tworzenia ani jednego worktree
  • send-keys jako prawdziwego kanału agent-agent, razem z dwiema flagami, bez których psuje ci wiadomości
  • capture-pane i wait-for jako pollingu i bariery — jak agent-orkiestrator nadzoruje workerów
  • Hooków agenta podłączonych do tmuxa, żeby „ten agent jest zablokowany” było dokładnym sygnałem, a nie zgadywaniem z ekranu
ZagadnienieWłaścicielDlaczego nie ten drugi
Izolacja plikówAgentclaude --worktree, /worktree w Cursorze, wątki worktree w Codeksie. Skryptowanie tego samemu dubluje dziś wspieraną funkcję
Izolacja runtime’u (porty, baza dev, nazwy Dockera)TyNikt tego za ciebie nie zrobi. Worktree izoluje pliki i nic więcej
Widoczność (który agent mnie teraz potrzebuje)tmuxOkna, status line, choose-tree. Agent wie tylko o sobie
Trwałość (zamknij klapę, wróć po SSH)Serwer tmuxaProces agenta umiera razem z terminalem, o ile coś poza nim nie przeżyje
Komunikacja między agentamitmuxsend-keys, capture-pane, wait-for. Żaden agent nie ma skrzynki odbiorczej

Instynkt każe dzielić panele. Działa do trzech agentów i rozsypuje się przy sześciu: panele stają się zbyt wąskie, żeby przeczytać diff agenta, i tracisz orientację, który jest który. Sesje skalują się lepiej, bo mają nazwy i skaczesz do nich po nazwie.

Układ, który się broni: jedna sesja tmuxa na zadanie, a w niej jedno okno na agenta, jedno na serwer dev, jedno na testy.

Okno terminala
# skok po nazwie, bez liczenia paneli
tmux switch-client -t agent-auth-refresh
# albo przejrzyj całą flotę jako drzewo
tmux choose-tree -Zs

Wrzuć flotę do status line, żeby „który agent mnie potrzebuje” dało się odpowiedzieć bez przełączania się gdziekolwiek. Wystarczą nazwy sesji:

~/.tmux.conf
set -g status-left-length 80
set -g status-left '#{?client_prefix,#[reverse],}[#S] '
set -g status-right '#(tmux list-sessions -F "#{session_name}#{?session_attached,*,}" | tr "\n" " ")'
set -g status-interval 5

Bazową konfigurację tmuxa — przemapowanie prefiksu, nawigację po panelach, history-limit pod długie transkrypty agenta — opisuje mistrzostwo terminala. Ten artykuł zakłada, że ta warstwa istnieje, i buduje na niej warstwę floty.

Cały skrypt — i zwróć uwagę, czego w nim nie ma: ani jednej wzmianki o git worktree. Tym zajmuje się agent.

#!/usr/bin/env bash
# bin/fleet -- jedna sesja tmuxa na zadanie; agent sam zarządza swoim worktree
set -euo pipefail
[ $# -gt 0 ] || { echo "użycie: bin/fleet <slug> [slug...]" >&2; exit 1; }
for slug in "$@"; do
# send-keys wpisuje do shella, więc slug w rodzaju 'x;id' uruchomiłby `id`.
# Waliduj, zanim cokolwiek dotrze do panelu.
case "$slug" in
*[!a-zA-Z0-9._-]*|"") echo "niepoprawny slug: $slug" >&2; exit 1 ;;
esac
session="agent-$slug"
if tmux has-session -t "$session" 2>/dev/null; then
echo "istnieje, pomijam: $session" >&2
continue
fi
# -d odłączona, -c żeby agent startował w katalogu repo
tmux new-session -d -s "$session" -c "$PWD" -n agent
tmux send-keys -t "$session:agent" "claude --worktree $slug" C-m
# drugie okno na to, co dane zadanie musi uruchamiać
tmux new-window -d -t "$session" -n run -c "$PWD"
done
first="agent-$1"
if [ -n "${TMUX:-}" ]; then
tmux switch-client -t "$first"
else
tmux attach -t "$first"
fi
Okno terminala
bin/fleet auth-refresh rate-limit-headers stripe-webhook-idempotency

Trzy sesje, trzy agenty, trzy worktree w .claude/worktrees/, każdy na własnym branchu worktree-<slug>. Nie napisałeś ani jednej komendy gita.

send-keys wpisuje tekst do panelu. Agent nie odróżnia tego od tego, że piszesz ty — i właśnie dlatego działa jako kanał komunikacyjny między agentami.

Okno terminala
tmux send-keys -t agent-api -l "Migracja z agent-auth-refresh właśnie wylądowała na main. Zrób rebase i puść ponownie testy integracyjne."
tmux send-keys -t agent-api C-m

Dwa szczegóły decydują o tym, czy to działa, czy po cichu psuje wiadomość:

  • -l wysyła tekst dosłownie. Bez tego tmux parsuje argument jako nazwy klawiszy. Wiadomość zawierająca Enter, Space albo ; zostaje zinterpretowana, a nie wpisana — i dostajesz pokaleczony prompt albo przypadkową komendę. Do prozy zawsze -l.
  • Zatwierdzaj osobnym wywołaniem. C-m to nazwa klawisza, więc nie pojedzie w tym samym argumencie -l. Wyślij tekst, potem wyślij nową linię.
Okno terminala
tmux capture-pane -p -J -t agent-api -S -200
  • -p wypisuje na stdout zamiast do bufora wklejania, więc możesz to przepuścić przez pipe.
  • -J scala zawinięte linie. Bez tego każda długa ścieżka pliku albo ramka stosu jest rozerwana na szerokości terminala, więc twój grep jej nie znajdzie. Ta flaga to różnica między wiarygodnym pollingiem a flaky.
  • -S -200 zaczyna 200 linii wstecz w scrollbacku, a nie na górze widocznego panelu.

Pętla nadzorcza na tym zbudowana — kształt, którego używa agent-orkiestrator do pilnowania workera:

#!/usr/bin/env bash
# bin/agent-watch <sesja> -- zgłoś, kiedy worker potrzebuje człowieka
set -euo pipefail
session="$1"
while tmux has-session -t "$session" 2>/dev/null; do
tail=$(tmux capture-pane -p -J -t "$session:agent" -S -40)
case "$tail" in
*"Do you want"*|*"Allow "*|*"(y/n)"*)
echo "BLOCKED $session"; break ;;
*"Error"*|*"error:"*|*"FAIL"*)
echo "ERROR $session"; break ;;
esac
sleep 15
done

To jest zgadywanie z ekranu i jest tak kruche, jak wygląda — nowe brzmienie promptu je psuje. Używaj tego jako awaryjnej ścieżki, nie jako mechanizmu. Mechanizmem są hooki, dwie sekcje niżej.

Polling pali cykle i dodaje opóźnienie. wait-for to prawdziwa bariera: proces czekający blokuje się, dopóki ktoś nie zasygnalizuje kanału.

Okno terminala
# w workerze, po tym, co faktycznie miało się udać
tmux wait-for -S migration-applied
Okno terminala
# w orkiestratorze
tmux wait-for migration-applied
tmux send-keys -t agent-api -l "Migracja zaaplikowana. Rebase na main i puść ponownie testy integracyjne."
tmux send-keys -t agent-api C-m

Nazwy kanałów żyją na serwerze tmuxa, więc dowolny panel może zasygnalizować dowolny inny — bez wspólnych plików i bez żonglowania PID-ami. To najczystszy prymityw zależności dostępny flocie: zadanie B czeka na kanale A-done, zadanie A sygnalizuje go, kiedy wyląduje, i nic nie odpytuje.

Polling przez capture-pane wydaje się kruchy, bo jest kruchy — wnioskujesz o stanie z wyrenderowanego tekstu. Hooki agenta odwracają to: agent sam mówi tmuxowi, co się stało, w chwili, w której się stało.

Hook Stop zmienia nazwę okna, gdy agent skończy, i sygnalizuje kanał nazwany po własnej sesji, żeby czekający mógł zablokować się na jednym konkretnym workerze. Hook Notification robi to samo dla „potrzebuję zgody”. Oba to jedna linia tmuxa.

{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "S=$(tmux display-message -p -t \"$TMUX_PANE\" '#{session_name}' 2>/dev/null); tmux rename-window -t \"$TMUX_PANE\" done 2>/dev/null; [ -n \"$S\" ] && tmux wait-for -S \"done-$S\" 2>/dev/null; true"
}
]
}
],
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "tmux rename-window -t \"$TMUX_PANE\" 'NEEDS-YOU' 2>/dev/null; tmux display-message -d 3000 'agent czeka na zgodę' 2>/dev/null; true"
}
]
}
]
}
}

Cztery rzeczy warte uwagi. $TMUX_PANE jest ustawione w każdym panelu tmuxa, więc hook celuje w siebie bez wpisywania sesji na stałe. Nazwa kanału jest wyprowadzana z sesji, a nie sztywna — ma to większe znaczenie, niż wygląda, i następny akapit jest o tym dlaczego. 2>/dev/null i kończące true sprawiają, że hook jest nieszkodliwy, gdy agent działa poza tmuxem — inaczej niezerowy kod wyjścia objawia się jako błąd hooka przy każdym zatrzymaniu. A nazwa okna staje się nośnikiem informacji: rzut oka na status line mówi, który agent cię potrzebuje, bez żadnego pollingu.

Kiedy hooki są na miejscu, bin/fleet-deps przestaje skanować ekran i zaczyna czekać na konkretnego workera:

Okno terminala
# sesja to agent-db-migration, więc jej hook Stop sygnalizuje done-agent-db-migration
tmux wait-for done-agent-db-migration

claude --worktree <nazwa> tworzy .claude/worktrees/<nazwa>/ na branchu worktree-<nazwa> i tam startuje. --worktree "#1234" odgałęzia się od pull requesta. Przy wyjściu Claude proponuje usunięcie worktree, jeśli jest czysty, i pyta, gdy jest w nim praca.

Dla flot liczą się dwa ustawienia. worktree.baseRef domyślnie ma wartość "fresh" (branch od domyślnej gałęzi na zdalnym); ustaw "head", gdy workery mają budować na twojej niewypchniętej pracy. A .worktreeinclude w katalogu repo kopiuje pliki z .gitignore, np. .env, do każdego nowego worktree.

Dla nieinteraktywnych workerów w panelu claude -p całkowicie pomija sprawdzanie trustu — ale oznacza też brak promptu przy wyjściu, więc nic nie sprząta worktree. Te usuwaj samodzielnie przez git worktree remove.

Agent w środku refaktoru umiera razem z terminalem. Serwer tmuxa nie.

  1. Odłączaj się, a nie wychodź. prefix + d. Agenty pracują dalej. tmux attach -t agent-auth-refresh podnosi flotę z powrotem, także z innej maszyny po SSH.

  2. Postaw flotę tam, gdzie może stać. Mały VPS albo zapasowa maszyna z serwerem tmuxa oznaczają, że zamknięcie laptopa nic nie zatrzymuje. ssh box -t tmux attach -t agent-auth-refresh to cały workflow.

  3. Przeżyj reboot świadomie. tmux-resurrect zapisuje i przywraca układ sesji i okien; tmux-continuum zapisuje go cyklicznie. Zauważ, co przywracają: układ, nie rozmowy agentów. Połącz to z resume samego agenta (claude --resume, codex resume), żeby wrócić do prawdziwego stanu.

  4. Podnieś scrollback, zanim będzie potrzebny. set -g history-limit 50000. Transkrypt agenta przebija domyślne 2000 linii w jednym refaktorze, a capture-pane -S -200 nie przeczyta tego, co tmux już wyrzucił.

Oskryptowałeś worktree, którymi agent już zarządza. Objaw to dwa worktree na zadanie albo agent sprzątający katalog, którego twój skrypt się jeszcze spodziewa. Wybierz jednego właściciela. W 2026 tym właścicielem jest agent.

send-keys wylądował w promcie o uprawnienia. Twoja instrukcja odpowiedziała na dialog tak/nie i agent zrobił coś, o co nie prosiłeś. Zawsze capture-pane przed send-keys, a najlepiej prowadź przekazania z hooków, żebyś pisał tylko do agentów, o których wiesz, że są bezczynne.

send-keys pokaleczył wiadomość. Zapomniałeś -l i tmux odczytał Enter albo C-c w twojej prozie jako klawisz. Objawy sięgają od ucięcia promptu do zabicia agenta.

Grepy po capture-pane nie widzą oczywistych trafień. Zapomniałeś -J, więc zawinięta ścieżka albo ramka stosu jest rozerwana na szerokości terminala. Dodaj -J i ten sam grep zaczyna działać.

wait-for wisi bez końca. Nikt nie zasygnalizował kanału — zwykle bo worker padł przed linią sygnalizującą. Sygnalizuj na obu ścieżkach (cmd && tmux wait-for -S ok || tmux wait-for -S failed) i rozróżniaj je po stronie czekającego, albo owiń czekanie w timeout.

Scrollback zjadł dowody. Domyślny history-limit to 2000 linii. Podnieś go do 50000 przed długim biegiem, nie po.

Sześć paneli nie przyspieszyło cię. tmux czyni fan-out tanim, co czyni łatwym przekroczenie własnego pasma review. Sufitem jest to, ile diffów potrafisz realnie ocenić — kalibracja w równoległości zespołowej, a wpływ na zużycie tokenów w optymalizacji kosztów.

Flotę zatrzymały limity, nie tmux. Każdy panel wydaje przydział tego samego konta. Agent, który „zawisł”, to często agent, który uderzył w limit. Sprawdź panel, zanim zaczniesz debugować skrypt.