Orchestrating terminal agents
Controla o emulador de terminal kitty por script para orquestrar trabalho em paralelo — abre N abas dinamicamente, roda comandos nelas, digita em TUIs interativas, manda teclas especiais (Shift+Tab, ESC, Ctrl+C) e lê a tela de volta; e, em cima disso, dispara N agentes de código, cada um na sua git worktree e na sua aba, sabe quando cada um terminou (hook Stop → barreira) e dispara a wave seguinte nas mesmas abas, até a limpeza das worktrees. Use quando o pedido envolver "abrir N abas", "rodar X em paralelo", "vários agentes ao mesmo tempo", "uma worktree para cada", "digitar isso naquela aba", "mandar Shift+Tab/ESC para o Claude Code", "avise quando todos terminarem", "quando acabar, mande eles commitarem", ou automatizar qualquer TUI que não tenha flag de linha de comando equivalente. NÃO use para rodar um comando único e pegar a saída (Bash resolve), nem para multiplexar sessões que precisem sobreviver ao terminal (isso é tmux).From its SKILL.md
npx -y skills add frederico-kluser/orchestrating-terminal-agentsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- 23 days oldThe repository was created 23 days ago. New is not bad, but a brand new repository carrying a familiar-sounding name is the shape a typosquat arrives in, and there has been no time for anyone else to find a problem with it.
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
15.5 KB, ~4.5k tokens by cl100k_base, as published. Nobody here has run it
Orquestrando agentes no terminal (kitty)
Três camadas
| Camada | Script | Para quê |
|---|---|---|
| Primitivas | scripts/kittyctl.py | Dirigir qualquer programa numa aba: criar, digitar, mandar tecla, esperar, ler a tela. |
| Receita | scripts/fanout.py | O caso pronto: N worktrees + N agentes + prompt, numa chamada. |
| Coordenação | scripts/wavectl.py | Fechar o ciclo: barreira até todos terminarem, broadcast da wave seguinte nas mesmas abas, limpeza das worktrees. |
# os scripts moram em scripts/ ao lado deste SKILL.md — instalado via symlink,
# o caminho canônico é o da skill:
K=~/.claude/skills/orchestrating-terminal-agents/scripts
python3 $K/kittyctl.py --help # primitivas
python3 $K/fanout.py --help # fan-out
python3 $K/wavectl.py --help # barreira / broadcast / limpeza
As três regras invioláveis
Não são estilo — cada uma vem de um comportamento medido que falha em silêncio se ignorado.
1. Nunca confie no exit code de um send-*
kitty @ send-key e kitty @ send-text setam disallow_responses: saem com exit 0 mesmo casando
zero janelas. Medido aqui — kitty @ send-key --match var:agent=NAOEXISTE enter → exit 0.
Um script errado parece funcionar sem fazer nada.
O kittyctl pré-checa todo matcher com ls (que reporta no-match) e aborta com exit 2 em vez de
mentir sucesso. A confirmação pós-envio fica com quem orquestra: leia a tela com capture/
wait-for depois de agir (é o que o fanout --via-keys faz antes de mandar Enter). Se você chamar
kitty @ na mão, faça as duas coisas.
2. Texto e Enter em chamadas separadas
O Claude Code liga bracketed paste (DECSET 2004). Com o envelope de colagem ativo, um \r no fim do
mesmo send-text cai dentro dos brackets em vez de submeter. E o tratamento de CR do Claude Code
já mudou entre versões — nunca assuma que newline final submete.
python3 $K/kittyctl.py send-text --match var:agent=1 --text 'meu prompt' # só digita
python3 $K/kittyctl.py send-key --match var:agent=1 enter # só submete
send-text sempre manda o texto por stdin (literal). Testado com $HOME, crases, aspas e barras —
chega verbatim. Use --paste só para prompt multi-linha.
3. Tecla especial vai por send-key, com nome simbólico
send-key shift+tab — nunca escape cru. O kitty escolhe a codificação conforme o modo que a TUI
negociou (\x1b[Z legado, \x1b[9;2u sob o kitty keyboard protocol); um escape fixo quebra no
segundo caso. send-raw existe só para sequências que não são tecla.
Gotchas que custaram tempo
- Shift+Tab CICLA, não liga plan mode. No Claude Code 2.1.218 a ordem é
auto → manual → accept edits → plan— 3 teclas a partir do default, e a ordem muda entre versões. Nunca conte teclas: cicle até a tela dizerplan mode on(é o quefanout.pyfaz emcycle_to_plan). Melhor ainda: use a flag--permission-mode plane não mande tecla nenhuma. - Toda worktree nova dispara o diálogo de confiança do workspace. Sem tratar, a aba fica parada
nele para sempre — inclusive no caminho por flags.
fanout.pydetecta e confirma (desligue com--no-trust). - Um agente que roda dentro do Claude Code vaza
CLAUDE_*eTMUXno ambiente. O filho acusa "Transcript saving is off — inherited CLAUDE_CODE_CHILD_SESSION" e acha que vive num pane tmux alheio. O comando é embrulhado em/usr/bin/env -u …(lista emLEAKY_ENV). - O socket some junto com a última janela. Fechar a última aba mata o processo do kitty e o
socket. Quem orquestra não pode assumir que a instância de ontem existe —
resolve_addr(start=True)sobe uma quando falta. --listen-onna linha de comando NÃO anexa o PID (fica@fanexato); só a diretivalisten_ondokitty.confvira@mykitty-<pid>. Por isso a descoberta varress -xlnpor prefixo em vez de adivinhar o nome.- Selecione por
--var, nunca por título — o TUI reescreve o título da janela. Matcher booleano funciona:--match 'var:fan=<run> and var:agent=2'. --next-tonão agrupa abas por remote control. O help avisa que ele é ignorado a menos que a janela casada esteja na aba alvo (a ativa) — medido: um fan-out de 3 abas se espalhou por 2 janelas. Para agrupar: crie a primeira sem--keep-focus(ela precisa virar a ativa), e antes de cada aba seguinte façafocus-window --match id:<âncora>e então--type=tab --keep-focus.launchroda o programa direto, sem shell. Encadear exigesh -c "… && exec claude". E--cwdprecisa existir: o path vai direto aospawn()em C, então diretório inexistente é falha dura, não fallback pra home. Crie a worktree antes do launch.
Flags antes de teclas
Se o programa tem flag para o que você quer, use a flag. Injeção de teclas é um recurso para quando não há alternativa — não é o caminho principal.
# determinístico: perfil + modelo + mode + effort + prompt, sem tecla nenhuma
claude --permission-mode plan --model opus --effort max "meu prompt"
Teclas ficam para: agentes sem flag equivalente (kimi, pi), intervir numa sessão já rodando
(/compact, ESC para interromper, responder um prompt de permissão) e o modo --via-keys.
Escolhendo perfil, modelo, mode e effort
O fanout.py mapeia cada eixo para a flag do Claude Code correspondente (todas verificadas no binário
2.1.218):
| Eixo | Flag | Valores | Default |
|---|---|---|---|
| Instalação | CLAUDE_CONFIG_DIR | --profile asd (~/.claude) · --profile dsa (~/.claude-secundaria) · os seus via FANOUT_PROFILES=nome=dir,… · --config-dir <dir> direto | asd |
| Modelo | --model | opus · fable · sonnet · haiku · opus[1m] · fable[1m] · nome completo (claude-fable-5) | o das settings do perfil |
| Mode | --permission-mode | --mode plan (ou o alias --permission-mode plan) · acceptEdits · auto · bypassPermissions · manual · dontAsk · none | plan |
| Effort | --effort | low · medium · high · xhigh · max · none | max |
| Permissões | --dangerously-skip-permissions | --yolo / --no-yolo | --yolo LIGADO |
Perfis são só nomes para CLAUDE_CONFIG_DIRs — a instalação inteira (credenciais, MCP, histórico) é
escolhida pelo dir, não por login. asd/dsa são os atalhos pessoais do autor; sinônimos main/
secondary também funcionam, e FANOUT_PROFILES/--config-dir cobrem qualquer outro layout.
Atenção ao default do yolo: todo launch de claude sai com --dangerously-skip-permissions a
menos que você passe --no-yolo. Em plan mode isso significa: aprovado o plano, a execução roda sem
gates de permissão. O binário do claude é resolvido por FANOUT_CLAUDE_BIN → PATH →
~/.local/bin/claude. Gotcha: um --model inválido não dá erro — o claude só imprime
"There's an issue with the selected model" e fica no seletor (a aba trava). Confira o nome; em
dúvida, omita e use o default das settings.
Onde as abas rodam (pwd)
- Com worktree (default): cada aba abre numa
git worktreenova, criada antes do launch (--cwdinexistente é falha dura de spawn). Convenção<pai>/<repo>.worktrees/<branch>. - Sem git —
--no-worktree: as N abas abrem direto em--cwd <dir>(default:--repo). É o controle de pwd puro, para quando não se quer branch nenhum.
Um prompt para todos, ou um por agente
--prompt e --prompt-file são repetíveis:
- 1 prompt → compartilhado por todos os N agentes (N independentes atacando a mesma coisa).
- N prompts → um por agente na ordem (dividir trabalho: agente 1 = Alpha, agente 2 = Beta…). Nesse
caso o próprio nº de prompts define quantos agentes subir, sem precisar de
--count. - Qualquer outra combinação (ex.: 2 prompts para 3 agentes) falha com mensagem clara em vez de adivinhar.
# 3 independentes, mesmo prompt
fanout.py --count 3 --repo ~/X --prompt "refatore o auth"
# 3 agentes, um prompt cada (count inferido)
fanout.py --repo ~/X --prompt "cuide do auth" --prompt "cuide do cache" --prompt "cuide dos logs"
# um arquivo por agente
fanout.py --repo ~/X --prompt-file a.md --prompt-file b.md
Prompts multi-linha e grandes
Resolvido sozinho, nos dois caminhos:
- Por flag (default): o prompt vai por argv. Newlines e tamanho não importam (limite prático
=
ARG_MAX, ~2 MB) e nada é reinterpretado. Passe com--prompt-file arquivo.mdou--prompt-file -(stdin) para prompts longos. --via-keys: multi-linha vai como bracketed paste (o\nfica literal dentro do envelope, não vira Enter); o Claude Code colapsa em[Pasted text #N +M lines]e envia o conteúdo inteiro no submit. Ofanout.pyverifica que o prompt chegou no composer (lendo a tela) antes de mandar Enter — testado com 62 linhas / ~4,8 KB contendo$, aspas e barras, chegando íntegro.
Waves: saber quando terminou e mandar a próxima
O wavectl.py fecha o ciclo do fan-out. O fanout injeta em cada claude um hook Stop
(via --settings, válido só naquela sessão) que grava uma linha JSONL por fim de resposta
em ~/.cache/fan/<run_id>/status/, e grava o manifest da run no mesmo dir — é o contrato
que o wavectl lê.
wavectl.py await --run <id> --notify # barreira: bloqueia até todos terminarem
wavectl.py status --run <id> # fotografia, sem bloquear
wavectl.py send --run <id> --text 'commite tudo com mensagem descritiva' # wave 2 (o modelo executa)
wavectl.py await --run <id> # barreira: todos commitaram
wavectl.py send --run <id> --text '/exit' # /exit é do TUI — VOCÊ digita, não o modelo
wavectl.py await --run <id> --expect exit # barreira: as abas fecharam
wavectl.py finish --run <id> # remove worktrees (repo principal, serializado)
Três regras, todas de comportamento medido ou documentado:
- A barreira é por nº de wave, não por relógio — o fanout marca a run na wave 1 e
cada
wavectl sendincrementa o contador; oawait --expect stopespera cada agente acumular ≥N stops. Assim tanto faz o agente terminar antes ou depois doawaitcomeçar (baseline por relógio perde essa race — medido).Stopdispara por fim de resposta: um agente parado pedindo input também conta como terminado (é o que você quer — precisa de atenção). Runs com--no-hookcaem no fallback heurístico (rodapé idle estável por 3 leituras), que dá falso positivo: em 2026-07-25 ele declarou "1/1 terminaram, stops 0, tela idle" um minuto após o launch, com os dois agentes em plena execução — ele pega a janela entre duas chamadas de ferramenta. Sinal muito melhor, se precisar de um:esc to interruptsó existe no rodapé enquanto o Claude Code trabalha; exija AUSÊNCIA em duas leituras seguidas. Mas o certo é não usar--no-hook(veja abaixo). - O orquestrador "é avisado" rodando
awaitcomo background task — a conclusão da task É a notificação.--notifymanda notify-send para o humano. - O agente NUNCA remove a própria worktree — o git recusa (
cannot remove worktree … is the current working directory) e apagar o diretório por baixo dele deixa lock e metadata stale. A wave final é "commita" (linguagem natural, o modelo executa) seguida do broadcast literal/exit(slash command é do TUI — quem digita é você; mandar "rode /exit" vira um turn do modelo explicando que ele não pode — medido). Ofinishremove a partir do repo principal, sob o mesmo lock da criação, recusa worktree suja (sem--force) e lista os branches para o merge — um a um, nunca octopus, que aborta inteiro no primeiro conflito.
Detalhe: o --settings injetado SOMA aos hooks do projeto, não os substitui. Hooks
próprios continuam valendo, e --no-hook NÃO é necessário para preservá-los. Medido em
2026-07-25 (Claude Code 2.1.220) com projeto de controle:
PreToolUse do projeto | Stop do projeto | Stop injetado | |
|---|---|---|---|
sem --settings (controle) | bloqueou | rodou | — |
com --settings só-Stop | bloqueou | rodou | rodou |
A fusão é por evento e aditiva: os dois Stop rodaram na mesma sessão. Portanto use o
hook (o default) mesmo em projeto com hooks próprios, inclusive de segurança. Escolher
--no-hook "para não desligar os hooks do projeto" é trocar uma barreira determinística
por um fallback de tela que dá falso positivo — sem ganho nenhum de segurança.
Receitas
Ver references/recipes.md — fan-out básico, um agente por item de uma lista, interromper todas as
abas, /compact em todas, dirigir um TUI qualquer.
# 3 agentes, 3 worktrees, opus 1M em plan mode, prompt submetido
python3 $K/fanout.py --count 3 --repo ~/Projects/XPTO --model 'opus[1m]' --prompt "refatore o auth"
# 2ª conta, fable, aceitando edits, prompt longo por arquivo
python3 $K/fanout.py --count 2 --repo ~/Projects/XPTO --profile dsa --model fable \
--mode acceptEdits --prompt-file tarefa.md
# sem worktree: 4 abas no mesmo diretório
python3 $K/fanout.py --count 4 --no-worktree --cwd ~/Projects/XPTO --prompt "..."
# ver o que faria, sem fazer · fechar tudo de uma run
python3 $K/fanout.py --count 3 --repo ~/Projects/XPTO --prompt "..." --dry-run
python3 $K/kittyctl.py close --match var:fan=<run_id>
As worktrees seguem uma convenção estável (<pai>/<repo>.worktrees/<branch> +
git config branch.<b>.origdir/.origbranch), então qualquer fluxo de finalização de worktree
(commit/push/merge) consegue descobrir de onde ela saiu e para onde volta.
<evolution>
Ao concluir uma tarefa que usou esta skill:
- Verificado de verdade (aba abriu, tecla chegou, tela confirmou)? Lembre que
send-*mente sucesso — só persista o que você leu de volta comcapture. - Append em
LEARNINGS.mdcom data e fonte: ordem nova do ciclo de modos, diálogo novo na inicialização de um agente, env var nova vazando, quirk de versão do kitty. - Padrão estável → destile para o
SKILL.mde incrementeversion. - Não commite sozinho — deixe como
git diff.
What ships with it: 8 files
98.0 KB alongside SKILL.md, 3 of them executable
references/
- recipes.md8.8 KB
scripts/
- fanout.pyruns24.3 KB
- kittyctl.pyruns16.9 KB
- wavectl.pyruns15.9 KB
- .gitignore13 B
- LEARNINGS.md20.6 KB
- LICENSE1.0 KB
- README.md10.4 KB