Tentacruel
Multiplexador de worktrees para o Claude Code.
Cada aba = um git worktree + uma branch + um agente Claude Code com cwd
fixado naquele worktree + uma conversa. Os quatro andam juntos porque um
worktree só tem uma branch por vez: é isso que torna duas branches paralelas em
vez de alternadas. Três abas no mesmo repositório nunca enxergam os arquivos umas
das outras, porque cada uma está fisicamente em um diretório diferente.
São duas camadas, e só duas: o trilho lista repositórios, e a tira de abas lista as worktrees de um deles.
App web local, single-user, sem auth. Roda em localhost.
┌──────┬─────────────────────────────┬──────────────┐
│ trilho│ aba1 │ aba2 │ arquivo.py │+ │ Arqs | Diff │
│ de ├─────────────────────────────┤ + commit / │
│ repos│ chat da aba + terminal │ push │
└──────┴─────────────────────────────┴──────────────┘
Instalação
uv tool install tentacruel
tentacruel
O Tentacruel é uma aplicação Python que serve a própria interface, então o
"executável" é um pacote e não um .app.
Não há assinatura nem notarização da Apple envolvidas, e isso é escolha e não
omissão. Um .app baixado de um site precisa de conta de desenvolvedor paga
e de notarização; sem elas, o Gatekeeper avisa sobre malware em toda instalação.
Um pacote instalado por uma ferramenta que a pessoa já usa não passa por esse
caminho — e o público deste app já instalou o CLI do Claude Code pelo terminal.
Como app do macOS, com ícone
tentacruel install-app
Cria o Tentacruel.app em ~/Applications — ele aparece no Launchpad e no
Spotlight, e pode ir para o Dock. Clicar nele sobe o servidor e abre uma janela
própria, sem barra de endereço: o WebKit é o do próprio macOS, então nada vem
embutido e nenhum navegador precisa estar instalado.
Isso continua sem envolver a Apple, e não por descuido. O Gatekeeper age sobre o atributo de quarentena, que o navegador põe no que você baixa de um site. Um bundle gerado na sua máquina pelo pacote já instalado nunca recebe esse atributo: não há o que notarizar, nem conta de desenvolvedor a pagar.
~/Applications e não /Applications de propósito — a pasta do sistema exige
autenticação de administrador, e instalação que pede senha é instalação que
parte das pessoas abandona.
Para remover, apague a pasta:
rm -rf ~/Applications/Tentacruel.app
E tentacruel --window abre a mesma janela sem criar app nenhum, se você quiser
só experimentar.
Pré-requisitos
| O quê | Por quê | Como conferir |
|---|---|---|
| macOS ou Windows | as duas plataformas com suporte; no Linux ele sobe, mas sem seletor de pasta, janela própria nem cofre do sistema — o terminal embutido, esse funciona, porque o pty é do POSIX | — |
| Claude Code instalado e logado | o agente usa a sua assinatura; não há ANTHROPIC_API_KEY |
claude --version e, se preciso, claude → /login |
| git 2.30+ | worktrees são o produto inteiro | git --version |
| uv | é quem instala, e traz o Python junto | uv --version |
Node não está na lista de propósito: a interface já vem compilada dentro do pacote. Ele só é necessário para trabalhar no código, e está em Desenvolvimento.
No Windows
uv tool install tentacruel
tentacruel
O Claude Code no Windows tem de ser o instalador nativo, e não o do npm.
irm https://claude.ai/install.ps1 | iex
Não é preferência. O npm install -g @anthropic-ai/claude-code entrega um
claude.cmd, e o SDK que o Tentacruel usa se recusa a executar um script de
lote — o Windows roda .bat/.cmd através do cmd.exe, que reanalisa a
linha de comando inteira, e não existe forma confiável de escapá-la (é a classe
do CVE-2024-24576). Instalar pelo npm ali é instalar exatamente o que não vai
rodar. A tela de Configurações → Provedores já oferece o comando certo.
Em Windows x64 há uma boa surpresa: o pacote do SDK traz o claude.exe
dentro, então na maior parte das máquinas o agente funciona sem instalar
nada. O instalador nativo acima é para quem quer o CLI também no terminal.
Duas coisas a saber:
-
--reloadnão funciona no Windows pela mesma razão que omake devnão funciona: com reload ligado o uvicorn escolhe um event loop que não abre subprocesso, e aí toda chamada agitmorre. Otentacruelnormal pede o loop certo e não passa por isso. -
Caminhos longos. Os worktrees ficam em
<repo>\.tentacruel\worktrees\<nome>\, e isso come caminho. Se o seu repositório for fundo, ligue o suporte a caminhos longos do Windows (LongPathsEnabled) — sem ele, umnode_modulesestoura o limite de 260 caracteres. -
O terminal usa o ConPTY, pelo
pywinpty, que entra como dependência só no Windows e vem com wheel pronto — ninguém precisa de compilador. Os cinco shells que ele oferece estão na tabela de Terminal, e ocmd.exedo%COMSPEC%é a garantia de que sempre há um. Uma armadilha vale ser dita: o Git Bash é derivado dogit.exeinstalado, e nunca de umbash.exeachado no PATH — o do PATH é oSystem32\bash.exe, que é o lançador do WSL, e abriria um shell num sistema de arquivos onde o worktree não existe.
O .tentacruel/setup.sh continua valendo: se o repositório tiver um
setup.ps1, ele ganha; se só houver o .sh, o Tentacruel usa o bash que veio
com o Git for Windows.
O agente autentica pelo login existente do Claude Code. Tentacruel nunca lê, pede ou
configura ANTHROPIC_API_KEY.
Aviso de atualização
O app volta a perguntar sozinho: de meia em meia hora, e sempre que a janela
recupera o foco — com trava de dez minutos, senão cada alt-tab pagaria a
conta, porque /api/system confere as dependências junto. Antes ele
perguntava uma vez, ao abrir, e este é um app que fica aberto por dias:
na prática o aviso nunca chegava a quem não reiniciava.
Quem fala com o índice de pacotes é o backend, no máximo a cada 6 horas — o relógio da tela dispara a pergunta, e o cache decide se ela chega ao PyPI. Toda falha é silenciosa: não saber se há atualização não é problema que mereça ir para a tela de alguém.
Quando há versão nova, ela aparece em dois lugares, com papéis diferentes — e a divisão é o ponto:
- um cartão no canto superior direito, com Atualizar e Dispensar. É o empurrão, e sai no primeiro clique em fechar. A dispensa é lembrada por versão: quem dispensou respondeu àquele aviso, não a todos os futuros;
- um ponto no botão de Configurações, no trilho, que fica aceso até a atualização acontecer. É a lembrança. Sem ele, dispensar o cartão uma vez escondia para sempre que existe versão nova — e o cartão sozinho seria um aviso que a pessoa pode apagar por engano e nunca mais ver.
O ponto leva a Configurações → Sobre, onde a versão disponível aparece e o botão de atualizar também mora — um lembrete que aponta para uma tela que só informa é um lembrete inútil.
O cartão era uma faixa no topo do app. Deixou de ser: uma faixa cobra altura de todas as telas o tempo todo por algo que aparece uma vez por versão.
TENTACRUEL_CHECK_FOR_UPDATES=false desliga — e desligado, este app não fala
com nada além do serviço de coordenação do próprio usuário.
Uso em 60 segundos
-
Registrar um repo — Configurações → Repositórios → Escolher pasta (abre o diálogo nativo do sistema) ou cole o caminho absoluto. Qualquer subdiretório serve; o Tentacruel resolve a raiz.
-
Abrir uma aba — botão
+no pé do trilho (ou⌘N): nome, branch base e modo de permissão. O nome vira o slug:Refatorar Auth→ branchwt/refatorar-auth, worktree.tentacruel/worktrees/refatorar-auth. Renomear a aba depois muda só o rótulo — o nome da branch é fixo, porque renomeá-la quebraria push já feito, PR aberto e o que estiver empilhado nela. -
Conversar — cada aba é um agente independente na branch dela. Vários podem trabalhar em paralelo; a bolinha no trilho pulsa enquanto algum agente daquele repositório executa.
-
Revisar — aba Diff à direita mostra o que ainda não foi commitado e o que já está commitado na branch da aba. Commit e push manuais ficam no rodapé do painel.
-
Fechar a aba — o
×da aba pergunta antes, porque isto mexe no git: o worktree sai do disco e a branch vai junto se o trabalho dela já estiver em outro lugar. O aviso conta o que existe ali — mudanças não commitadas e commits exclusivos — antes de você decidir."Em outro lugar" tem duas leituras, e as duas contam. A primeira é o
git branch -d, que aceita quando cada commit já está na base ou no remoto — uma branch empurrada passa, porque o trabalho está a salvo lá. A segunda existe porque o-dcompara sha, e o fluxo mais comum não preserva sha nenhum: mesclar um PR com squash cria um commit diferente, e para o-daquela branch nunca foi mesclada. Ogit cherrycompara por conteúdo, e quando ele diz que tudo já foi aplicado, a branch sai. Sem isso, cada PR fechado deixava umawt/*para trás, para sempre.Arquivar continua existindo para pausar sem fechar: para o agente, preserva tudo, e Reabrir traz a aba de volta inteira.
Atalhos
| Atalho | Ação |
|---|---|
⌘1…⌘9 |
troca para o n-ésimo repositório |
⌘N |
nova aba |
⏎ / ⇧⏎ |
envia / quebra linha no composer |
⌘Né reservado pelo Chrome e pelo Safari para "nova janela" e pode não chegar à página. O botão+do trilho faz o mesmo.
Como o isolamento funciona
Ao abrir uma aba o backend executa:
git -C <repo> worktree add <repo>/.tentacruel/worktrees/<slug> -b wt/<slug> <base>
e instancia o agente com cwd = <worktree>. Três camadas garantem o isolamento:
- Filesystem — worktrees são checkouts separados. O agente da aba A literalmente não tem os arquivos da aba B no seu diretório.
cwddo SDK — o processo do Claude Code nasce dentro do worktree; caminhos relativos resolvem lá.- Permissões — em
acceptEdits, o CLI recusa escritas fora do diretório de trabalho (verificado: uma tentativa de escrever em~/foi negada). - Instrução — o
system_prompt_suffixdiz ao agente que a branch é dele e proíbegit switch,git checkout <branch>egit worktree add. Trocar de branch ali não moveria só o agente: moveria o diretório inteiro debaixo de quem está olhando, levando junto o terminal e o diff que a tela mostra.
.tentacruel/ é adicionado automaticamente ao .git/info/exclude do repositório —
o seu .gitignore nunca é tocado.
Atenção:
.git/info/excluderesolve o git, mas não ferramentas que varrem o diretório por conta própria. Rodarpytest,ruff,eslintou um watcher na raiz do repo passa a enxergar cópias dos seus arquivos em.tentacruel/worktrees/*. Se isso incomodar, exclua o diretório na config da ferramenta — por exemplonorecursedirs = [".tentacruel"]no pytest,extend-exclude = [".tentacruel"]no ruff,ignores: [".tentacruel"]no ESLint. Se preferir os worktrees fora do repo, aponteTENTACRUEL_WORKSPACE_DIRNAMEpara outro nome ou ajusteworktrees_root()emservices/worktree.py— o caminho é calculado num lugar só.
O quadro: como um agente sabe dos outros
O isolamento tem um preço: cada agente vê só o próprio worktree, e portanto não sabe que os outros existem. Dois podem reescrever o mesmo arquivo ao mesmo tempo, em branches diferentes, e a briga só aparece no merge.
O arquivo .tentacruel/AGORA.md fecha esse buraco. Ele é derivado e reescrito
inteiro a cada varredura — a mesma que já calcula colisões, de carona, porque
os dois comandos de git por aba já foram pagos ali. Cada aba aparece com:
- a posição na ordem de abertura (
1ª,2ª…); - os arquivos que ela tocou (
git status+git diff --name-only <base>...HEAD); - com quem ela colide, nomeada pela branch — quem lê é um agente, e ele não conhece aba nenhuma por número;
- o que o agente dela declarou.
Dois autores, e de propósito. O app escreve os fatos que mede; o agente
escreve a intenção dele em .tentacruel/intencao.md, dentro do próprio
worktree, e o quadro só lê. Se ele nunca escrever, o quadro mostra só os
fatos — que é justamente o motivo de os fatos não virem de uma promessa.
A ordem de abertura é o critério de desempate. Se uma aba anterior já está tocando o arquivo que você precisa, o trabalho dela foi pedido antes: espere ou faça outra coisa. Se a aba que o toca veio depois, siga. A regra é arbitrária, mas é a mesma para os dois lados — e é isso que a torna utilizável.
O quadro chega ao agente pelo mesmo caminho da memória: uma linha @ no
CLAUDE.local.md da raiz do repositório, que é import nativo do Claude Code e
não um pedido em prosa. As worktrees ficam abaixo da raiz, então herdam. O
arquivo está no .git/info/exclude — não aparece em git status nem em PR.
Seletor de pasta
O botão Escolher pasta existe porque o browser não consegue
entregar um caminho absoluto — nem webkitdirectory nem a File System Access
API expõem isso. Como o backend roda na sua própria máquina, ele abre o diálogo
nativo do sistema e devolve o caminho.
Consequências:
- Hoje só no macOS, via
osascript(choose folder). Onde não há seletor nativo o endpoint devolve501e sobra o campo de texto — que continua aceitando um caminho absoluto colado. - Uma janela por vez: um segundo pedido recebe
409enquanto a primeira estiver aberta. - Se você fechar a aba com o diálogo aberto, ele fica órfão até o timeout de 3 minutos. Basta responder à janela do Finder que ainda está na tela.
Configurações
Ícone de ajustes no rodapé do rail. Tudo fica no banco, então sobrevive a trocas de browser.
| Seção | O que faz |
|---|---|
| Repositórios | registra e remove repos, e define provedor/modelo/esforço padrão por repositório — herdados por abas novas daquele repo |
| Provedores de IA | lista os CLIs: instalado ou não, versão, caminho, como logar. Botão Instalar roda o comando com a saída transmitida ao vivo |
| Aparência | tema, densidade e layout (posição do rail e do painel) |
| Dependências | git, Node, uv e npm com versão, caminho e o porquê de cada um |
| Idioma | português, inglês e espanhol |
| Sobre | versão, caminhos, atalhos e a notificação de fim de tarefa |
Temas
Cinco temas — Escuro, Carvão puro (AMOLED), Brasa, Claro e Papel/Sépia — são
conjuntos de tokens, não folhas de estilo paralelas. Cada um é um bloco
[data-theme="…"] em frontend/src/styles/tokens.css que sobrescreve as
variáveis; nenhum componente sabe que temas existem.
O seletor prova isso: cada miniatura carrega data-theme próprio, então é
o tema que anuncia — desenhada com os mesmos tokens que o app usa. Não há uma
segunda definição de cor para manter em dia.
Adicionar um tema = adicionar um bloco em tokens.css e uma linha na lista de
AppearanceSettings.
Densidade
Um multiplicador (--wt-density) alimenta espaçamento, tipografia e as
dimensões de layout. De 70% a 140%, em passos de 5%.
Idioma
Português (Brasil), inglês e espanhol. O catálogo de pt-BR é a fonte: os outros idiomas são tipados contra ele, então uma chave faltando quebra o build em vez de virar texto vazio em produção.
Adicionar um idioma são dois passos: copiar frontend/src/i18n/locales/en.ts,
traduzir os valores, e somar uma entrada em LOCALES (frontend/src/i18n/index.ts).
Nenhuma tela muda.
O que ficou de fora: o Carcará oferece 18 idiomas. Eu parei em três — os que consigo revisar. Escrever mais 15 traduções que não sei avaliar geraria texto de qualidade desconhecida na sua interface; a infraestrutura está pronta e eu adiciono qualquer idioma que você pedir, sinalizando quais não posso revisar.
Instalação de provedores
O botão Instalar abre /ws/install/{provider} e transmite a saída do
comando. O comando vem do registry no backend — o cliente só diz qual
provedor, nunca o que rodar.
Instaladores rodam sem terminal interativo (stdin fechado, CI=1,
npm_config_yes=true). Um instalador que insista em perguntar falha rápido em
vez de travar para sempre; nesse caso o comando aparece na tela para você rodar
no seu terminal. Timeout de 15 minutos, e o processo morre por process group.
Provedor e modelo por worktree
Cada aba escolhe seu próprio provedor de IA, modelo e nível de esforço — no modal de criação e no header, trocável a qualquer momento.
| Controle | O que faz | Como aplica |
|---|---|---|
| Provedor | qual backend de IA roda neste worktree | trocar reinicia o agente (históricos não são portáveis entre provedores) |
| Modelo | Padrão, opus, sonnet, haiku, fable |
ao vivo, sem reiniciar nem perder a conversa (set_model() do SDK) |
| Esforço | auto, low…max |
reinicia o agente e retoma via resume, então o contexto sobrevive |
São aliases, não ids fixos: opus sempre aponta para o Opus mais recente, então
a lista não envelhece. Um id completo (claude-opus-4-5-20251101) também é
aceito. Padrão = o que o seu CLI já estiver configurado para usar.
Rodar haiku numa aba de documentação e opus numa refatoração, em
paralelo no mesmo repo, é o caso de uso.
Autenticação: sempre a sua assinatura
Nenhum provedor usa API key. Cada backend dirige o CLI oficial do provedor,
que já carrega o seu login — hoje é o claude (assinatura do Claude Code). Um
provedor futuro seguirá a mesma regra: o CLI dele, o login dele, a sua conta.
O Tentacruel nunca lê, pede ou repassa ANTHROPIC_API_KEY ou equivalente.
O catálogo em GET /api/providers diz, por provedor, se o CLI está instalado
(available) e como logar (auth_hint, install_hint). O que não está
instalado aparece desabilitado na UI, com a instrução de instalação no tooltip.
Adicionando um provedor (o que falta)
A arquitetura está pronta, mas só o Claude Code está implementado — nenhum outro CLI de agente estava instalado nesta máquina para eu construir e testar contra. Para somar um:
- Escreva
services/agents/<provider>.pyimplementando o protocoloAgentBackend(start,send,interrupt,set_permission_mode,set_model,stop) e emitindo os dataclasses deagents/base.py. - Adicione um
ProviderSpecemservices/agents/registry.py.
Só isso. O manager, as rotas, o WebSocket, o banco e o frontend inteiro leem o
catálogo — nenhum deles precisa mudar. O ponto crítico de qualquer adapter é
fixar o cwd no worktree: é o que sustenta o isolamento.
Navegador de arquivos
Aba Arquivos no painel lateral — a primeira, e a que o painel abre. É o que se procura primeiro; o diff só interessa depois que o agente escreveu alguma coisa. Árvore preguiçosa — um diretório por chamada — então um repositório grande custa o mesmo que um pequeno.
Terminal
Cada aba mostra o chat, o terminal, ou os dois — três botões na barra da própria aba, à esquerda do seletor de branch. Ali e não na tira de abas de propósito: é dentro da aba que a escolha se lê como sendo daquela aba, e a barra que já mostra a branch é onde mora o resto do contexto dela.
A escolha é de cada aba, e não do app. Uma aba pode ficar em chat + terminal enquanto a vizinha fica só no terminal; trocar de aba troca o que se vê, e nada mais. É por isso que ela mora na barra da aba, ao lado do modelo e do modo de permissão, e não nas preferências — e é por isso que ela volta igual depois de recarregar a página.
No modo dividido os dois ficam lado a lado, com uma divisória vertical: uma conversa e um terminal são duas colunas de texto que rolam por conta própria, e empilhá-las cortaria a altura das duas — logo o terminal, que precisa de linhas. Numa tela larga, a largura é o que sobra.
A divisória, essa é do app: onde você a largou é onde ela volta, em qualquer conversa. É preferência de como cortar a tela, não do que aquela aba mostra.
O terminal é um shell de verdade, com cwd no worktree da aba. Não é um
visualizador de log: vim, htop, git rebase -i e um npm run dev que
pinta barra de progresso funcionam, porque do outro lado há um pseudoterminal
e não um pipe.
O modo é da aba, e o terminal também. Cada aba escolhe se olha para o chat, para o terminal ou para os dois, e o shell que aparece é o daquele worktree — um por aba, na branch daquela aba.
O terminal sobrevive a trocar de aba: um npm run dev no ar não pode cair
porque você foi olhar outra branch. Fechar a aba do navegador também não
derruba: o processo vive no servidor, o histórico da tela fica guardado lá, e
quem volta recebe de volta o que já tinha rolado.
Arquivar ou fechar a aba mata o shell junto — antes de mexer no disco. Um
shell com o cwd dentro do worktree segura o diretório aberto, e no Windows
isso é um WinError 32 no meio da remoção.
O prompt é só o nome da branch. O resto — worktree, provedor, uso — já está na tela, e o prompt de cada um costuma repetir diretório e branch num terminal que existe para um worktree só.
Nada da sua configuração é editado: o .zshrc continua onde está e é carregado
por dentro, então PATH, aliases e funções chegam inteiros. O que muda é só o
prompt, e o mecanismo é diferente em cada shell — ZDOTDIR com shims no zsh,
--init-file no bash e no Git Bash, --init-command no fish, $ENV no sh,
-Command no PowerShell, a variável PROMPT no cmd. Os arquivos são do app,
vivem numa pasta da aba e somem com ela.
O WSL é a exceção declarada: quem abre o shell é o serviço do outro lado, com o
shell de login da distribuição, e alcançar aquele prompt daqui seria escolher o
shell por você. Lá só atravessa a variável TENTACRUEL_BRANCH, pelo WSLENV —
quem quiser, põe no próprio prompt.
Qual shell. O Tentacruel detecta o que a máquina tem e usa o primeiro da ordem de preferência do sistema; o seletor no topo do painel troca, e a escolha é lembrada. Se o shell escolhido sumir — outra máquina, desinstalado — ele cai no padrão em silêncio, em vez de abrir uma tela preta com erro.
| Sistema | Ordem de preferência |
|---|---|
| macOS | zsh, bash, fish, sh |
| Linux | bash, zsh, fish, sh |
| Windows | PowerShell 7 (pwsh), Windows PowerShell 5.1, Git Bash, WSL, cmd.exe |
O $SHELL de quem está logado ganha do PATH quando aponta para um shell do
catálogo — é a resposta certa para quem trocou de shell e não quer o padrão do
sistema de volta.
A garantia é o último da lista. No Windows o cmd.exe vem do %COMSPEC%,
que existe até em máquina travada por política de domínio; no POSIX, o sh.
Ninguém fica sem terminal.
As cores saem dos mesmos tokens do resto da interface (--wt-ansi-* em
tokens.css), então trocar de tema repinta o terminal junto.
Ditado por voz
O microfone na caixa de mensagem grava, transcreve e põe o texto no campo — não envia. Transcrição erra, e ler antes de mandar é o ponto.
Quem transcreve é um CLI da sua máquina, pelo mesmo princípio dos provedores
de IA: o Tentacruel dirige o que já está instalado em vez de embutir motor
próprio. Embutir o openai-whisper arrastaria o torch junto; o faster-whisper
arrastaria o CTranslate2 — e os dois baixam modelo no primeiro uso. Um
multiplexador de worktrees não tem por que pesar um giga por causa de um
microfone.
services/transcribe.py conhece dois:
| CLI | instalar | observação |
|---|---|---|
whisper-cli (whisper.cpp) |
brew install whisper-cpp |
o mais rápido em Apple Silicon, mas precisa de um modelo ggml — procurado em ~/.cache/whisper.cpp e nos diretórios do brew |
whisper (OpenAI) |
brew install openai-whisper |
mais lento, porém baixa o próprio modelo: funciona assim que instala |
Sem nenhum deles pronto, o microfone não aparece — botão que não funciona é pior que botão ausente. Configurações → Transcrição de voz mostra o estado de cada um e o comando para instalar.
A caixa ganha borda de destaque quando o campo está em foco — cor primária com um anel fino, em vez de só um cinza mais forte.
O áudio é gravado como WAV mono de 16 kHz no próprio navegador
(lib/recorder.ts), que é exatamente o que o whisper.cpp exige e o que o
whisper aceita — por isso nem o app nem você precisam de ffmpeg. No servidor
ele vai para um arquivo temporário, passa pelo CLI e some: recado de voz não é
coisa para deixar no disco.
Anexos
A caixa de mensagem é uma pilha, e as medidas fecham:
4px padding do cartão (--wt-composer-padding)
54px campo de texto (--wt-composer-field-height, com 8px de padding)
32px faixa de controles (--wt-composer-bottom-height, um botão de ícone)
4px padding do cartão
────
94px (--wt-composer-height)
O campo tem caixa própria e mais folgada que o resto — 8px contra 4px —
porque é a parte em que se escreve. Entre ele e os controles não há régua:
os dois são a mesma caixa. A régua de 0.5px existe só acima da faixa de anexos,
que separa coisas de naturezas diferentes; essa faixa tem 28px
(--wt-strip-height), a mesma da linha de stats acima do cartão, e rola para o
lado em vez de empurrar o texto para baixo.
Todo botão de ícone do app é 32×32 (--wt-icon-button-size) com um glifo
de 18px (--wt-icon-glyph-size) centralizado nele. O respiro é o que
sobra — 7px de cada lado — então não existe padding para manter em sincronia
com nada.
A medida vive na variante icon do botão, não nos pontos de uso: IconButton
não tem prop de tamanho, e o iconLg que existia foi eliminado.
Cole (⌘V), arraste ou escolha pelo clipe. Tudo vai para
<worktree>/.tentacruel/attachments/, e essa escolha responde três perguntas de uma
vez: o agente alcança o arquivo, porque está dentro do próprio cwd — a
mesma fronteira de todo o resto, sem exceção aberta para upload; o git nunca
vê, porque .tentacruel/ já está no .git/info/exclude; e some junto com o
worktree, porque mora dentro dele.
Imagem viaja inline, como bloco base64 na mensagem do usuário: o agente enxerga a figura sem gastar uma chamada de ferramenta nela. Qualquer outro arquivo entra como caminho, para o agente ler com a mesma ferramenta que usa para o resto do worktree.
As dimensões saem do cabeçalho do próprio arquivo (PNG, JPEG, GIF, WebP) — o
app não carrega biblioteca de imagem para dizer 234×49. O caminho é
relativo ao worktree, então a miniatura no chip é servida pelo endpoint de
arquivo bruto que já existia: nenhum endpoint novo para desenhar o anexo.
O cliente manda caminhos; quem manda é o disco. Um caminho inventado, ou que aponte para fora do worktree, é descartado antes de virar mensagem.
O deslize
Todo elemento que muda de tamanho em passo discreto desliza em 400ms
(--wt-slide-duration), por dois primitivos com papéis distintos:
Collapse— para o que aparece e some: uma faixa de grid de0fra1fr, linhas para altura e colunas para largura. É o único jeito de transicionar até um tamanho vindo do conteúdo sem medir em JS antes.ResizeSlide— para o que fica e muda de tamanho. O CSS não dá conta aqui: transição dispara em mudança de valor computado, e conteúdo crescendo sobwidth: autonunca muda o valor — continuaauto. Então ele fixa a largura antiga, solta até a nova e devolve paraauto.
A diferença importa: um elemento que já está na tela e muda de tamanho deve mover só a diferença. Fazê-lo sumir e renascer com a transição é um efeito diferente, e pior.
Fechar uma aba de arquivo também desliza. O React remove um elemento no
instante em que ele deixa de ser renderizado, então sair com deslize é pedir
para sair, animar, e só então ir de fato: o botão de fechar marca a aba como
saindo, ela encolhe até zero, e o onClose dispara no fim.
A aba de worktree não desliza, e é de propósito: fechá-la mexe no git, então
o × abre uma confirmação. Animar a saída antes de perguntar prometeria uma
coisa que ainda pode ser cancelada.
Desliza: a faixa de anexos, a régua abaixo dela, as pastas da árvore, o raciocínio e a saída de ferramenta, o botão de "commits atrás", o lápis da aba, e a aba ao entrar e sair da renomeação.
Não desliza, de propósito: o balão de mensagem enquanto o agente escreve e o campo de texto enquanto você digita. Os dois mudam de tamanho de forma contínua, token a token e tecla a tecla; uma transição de 400ms ali faria o texto ficar permanentemente atrás do cursor. Animar mudança discreta dá a sensação de acabamento que você quer; animar mudança contínua faz o app parecer lento.
Pastas abrem e fecham deslizando em 400ms. O diretório é carregado antes de expandir: as linhas precisam já estar dentro do invólucro fechado para o slide ter o que mover — expandir primeiro faria a primeira abertura saltar enquanto todas as seguintes deslizam.
Clicar abre uma aba, e o que aparece depende do arquivo:
| tipo | o que abre |
|---|---|
| texto | editor, com contador de linhas e ⌘S |
imagem (.png, .jpg, .gif, .webp, .avif, .svg, .ico…) |
prévia sobre xadrez de transparência, com tamanho e dimensões |
.pdf |
o leitor nativo do navegador |
áudio (.mp3, .wav, .ogg, .m4a…) e vídeo (.mp4, .mov, .webm…) |
player com controles |
| resto | tipo e tamanho, sem fingir que dá para exibir |
O texto viaja dentro do JSON; mídia não. A página recebe só tipo, tamanho e
uma URL, e o navegador busca os bytes em /file/raw — por isso um vídeo de
20 MB abre sem inchar resposta nenhuma. O limite de 1 MB é dos arquivos que
entram na página; mídia vai até 64 MB.
Um .svg é desenho e o código que o desenha, então essa aba oferece os
dois: um botão alterna entre prévia e fonte, e na fonte o ⌘S volta. Um PDF
pequeno também decodifica como ASCII, mas "editar" ali é um jeito de corromper
o arquivo — quem decide é o tipo declarado, nunca a aparência dos bytes.
Só o que a tabela declara sai com o próprio Content-Type. Qualquer outra
coisa vai como application/octet-stream e attachment, então um .html
perdido no worktree nunca vira página rodando na origem do app.
- O git decide o que é ruído. A listagem passa por
git check-ignore, entãonode_modules,diste afins não aparecem..gitnunca é listado. - Arquivos alterados vêm marcados com o código do
git status(M,??,A), coloridos na árvore — o mesmo estado que a aba Diff mostra. - Clicar num arquivo abre ele como uma aba, ao lado das conversas, e o
arquivo é editável:
⌘S(ouCtrl+S) salva,Tabinsere dois espaços, e a aba mostra um ponto enquanto houver alteração não salva. O rascunho vive no estado da tela, então trocar de aba e voltar não perde o que você escreveu. - Ícones por tipo de arquivo vêm do Material Icon Theme (o mesmo do VS
Code). Só os SVGs mapeados em
ui/fileIcons.tsentram no bundle, e o Vite os transforma em data URI — nenhuma requisição extra em tempo de execução.
O editor é sem coloração de sintaxe — destacar sintaxe significaria embarcar um pacote de gramáticas. Ele tem numeração de linha e edição de texto puro.
Salvar tem as mesmas guardas de ler: o caminho é resolvido contra a raiz do worktree, arquivos binários são recusados, e não é possível criar arquivo novo por aqui — o editor abre sobre um arquivo que já existe.
O mesmo limite do agente
Todo caminho vindo do cliente é resolvido e conferido contra a raiz do worktree
antes de ser tocado. ../, caminho absoluto e symlink que aponta para fora são
recusados — inclusive o worktree da aba vizinha. A aba só enxerga o que o
agente dela enxerga, e há teste para cada uma dessas rotas de fuga.
Campo de mensagem
No estilo do Claude: um cartão arredondado único que segura o texto e os controles. Nada em volta pinta fundo ou borda — o composer flutua sobre o chat em vez de ficar numa barra preenchida.
Dentro do cartão, à direita, ficam o seletor de modelo e o de esforço, mais o botão de enviar. A barra da aba guarda o que é escolha da aba (provedor e modo de permissão); o modelo mora onde você escreve, como no Claude. Um lugar por conceito — o modelo não aparece duas vezes.
/ e @ no campo de mensagem
Dois gatilhos, e eles abrem a mesma lista sobre o campo:
| gatilho | onde | oferece |
|---|---|---|
/ |
só na posição 0 | comandos do Tentacruel, depois os do agente |
@ |
no começo ou depois de espaço | caminhos do worktree que o git não ignora |
/ só na posição 0 porque é ali que o CLI reconhece um comando — e porque
senão src/app.py abriria menu no meio da frase. @ no começo ou depois de
espaço pela razão inversa: um @ colado em texto é e-mail, decorator ou escopo
do npm, e nenhum deles é alguém pedindo um arquivo.
O catálogo do / não é nosso. O CLI resolve built-ins, os
.claude/commands/ do projeto e os do usuário quando conecta, e manda a lista
pronta no init — o app só guarda o que veio e repassa. É o que faz um comando
novo aparecer sem o Tentacruel saber que ele existe. Os que o CLI marca como
presos ao terminal (/exit e parentes) ficam de fora: aqui não é terminal, e
oferecer o que não tem como funcionar é pior do que não oferecer.
As duas seções agem diferente, e a lista diz qual é qual. Comando do agente vira texto e ainda precisa ser enviado; comando do Tentacruel é uma ação do app que acontece na hora e não chega ao agente — por isso ele também leva o campo junto, em vez de deixar o texto para trás fingindo que foi.
O @ insere o caminho como texto. A lista vem de
git ls-files --cached --others --exclude-standard: um subprocesso, e a
regra do gitignore vem do git em vez de ser reimplementada. O casamento é por
subsequência com o nome do arquivo valendo mais que o diretório, então
@app acha src/app.py antes dos quarenta arquivos que moram em app/.
Abrir um worktree que já existe
Nem todo worktree nasce aqui. Um pode ter vindo do git worktree add na mão, de
outra ferramenta (o --worktree do próprio Claude Code cria em
.claude/worktrees/), ou ter ficado para trás quando uma aba foi fechada
sem apagar o diretório. A caixa de nova aba oferece adotar esses.
O seletor "Como começar" só aparece quando há algo a adotar. Ao escolher Abrir um existente, a branch base some — um worktree que já existe já está numa branch, não há de onde cortá-lo — e o nome vira opcional: sem nome, usa o do diretório.
Adotar não provisiona nada: o diretório, a branch e o que está dentro já existem. Só ganham uma aba para um agente ser apontado a eles. Ficam de fora da lista os worktrees que já têm aba e o checkout principal do repositório — apontar um agente para a cópia de trabalho do usuário é exatamente o que este app existe para evitar.
Voltar para o fim
Quando a conversa não está no fim, um botão redondo aparece no rodapé dela e leva de volta à última mensagem. Some sozinho quando você chega lá, e enquanto está escondido não recebe clique — senão engoliria o toque na mensagem embaixo dele.
A mesma medida — "estou a menos de 80px do fim?" — governa duas coisas: esse
botão e o auto-scroll que segue o agente enquanto ele escreve. A diferença é que
o auto-scroll lê de um ref, para não re-renderizar a cada mensagem, e o botão
lê de um estado, porque precisa re-renderizar.
Manter o worktree em dia
Um worktree cortado de develop fica para trás enquanto o agente trabalha, e
quanto mais tempo passa, pior o merge no fim. Isso acontece sozinho: quando
a base ganha commits, o worktree os recebe — a branch e os arquivos no disco ao
mesmo tempo, porque o merge roda dentro do worktree.
Merge pequeno quase nunca conflita, e é essa a aposta: o que a sincronização contínua elimina é a divergência que envelheceu. A divergência concorrente — duas pessoas na mesma função ao mesmo tempo — nenhuma ferramenta resolve; para essa existe o alerta de colisão.
O header mostra o estado, e são quatro. O controle fica sempre na tela, e isso é conserto e não enfeite: enquanto ele só existia havendo o que trazer, o estado normal — que é o estado quase sempre — não desenhava nada, e ninguém tinha como saber que a sincronia existia.
| estado | o que significa |
|---|---|
| ⟳ em dia | nada a trazer. O tooltip diz com qual base e há quanto tempo foi conferido; clicar confere de novo, agora |
↓3 atrás |
há commits a trazer e um agente no meio de um turno. O botão faz na mão, parando os agentes antes |
aguardando |
há trabalho não commitado aqui. Desabilitado, porque parar o agente não resolveria — quem destrava é você |
em conflito |
o merge foi tentado e desfeito inteiro — e há um botão para pedir ao agente daquele worktree que resolva |
Ele espera, não interrompe. O caminho manual para os agentes antes de mesclar, que é certo para algo que você apertou. Para algo que dispara sozinho, matar um turno no meio por um merge de rotina não é.
As duas esperas não são a mesma, e tratá-las como uma escondia a única que
tinha saída. Worktree sujo e agente ocupado gravavam ambos aguardando, e a
tela desabilitava os dois — mas parar o agente é exatamente o que o caminho
manual faz, então o caso do agente ocupado era o caso que o botão resolve. O
resultado é que ↓N atrás habilitado nunca chegava à tela de ninguém, e o
caminho manual, vivo e testado, não tinha como ser acionado. Hoje ocupado grava
behind e sujo grava waiting, que era o que o enum já previa.
Quatro coisas disparam a verificação, e duas delas já eram eventos que
existiam: o fim de um turno, um commit (que também avisa quem foi
cortado daquela branch — é isso que faz empilhamento funcionar sem remote
nenhum), um relógio a cada TENTACRUEL_SYNC_INTERVAL segundos (60 por
padrão, 0 desliga), e você, clicando no controle. O relógio existe porque
um git pull na main no seu terminal não produz evento nenhum que este app
consiga ouvir — e esse é o caso comum, não o exótico; o clique existe porque
quem acabou de dar esse pull não quer esperar cinquenta segundos para o app
notar. O clique roda a mesma volta do relógio: confere, traz se o caminho
estiver livre, e revarre as colisões e o AGORA.md de carona.
O número é rev-list --left-right --count <base>...HEAD — o mesmo cálculo que
alimenta o ↓ do painel.
Merge, não rebase. Rebase reescreveria commits que o agente talvez já tenha enviado, e um rebase interrompido no meio deixa um worktree — em que um agente está prestes a escrever — num estado que ninguém pediu. Merge ou acontece inteiro, ou é desfeito inteiro.
Conflito desfaz o merge. Se der conflito, o merge --abort roda e os
arquivos que bateram são nomeados. Deixar marcadores de conflito num worktree
que um agente vai ler é entregar a ele um arquivo que parece código e não é.
Mudanças não commitadas bloqueiam. O merge reescreve arquivos; trabalho em voo seria pego no meio. O botão fica desabilitado e o tooltip diz o porquê.
No caminho manual, os agentes param antes — pela mesma razão da troca de
branch: os arquivos vão mudar debaixo do cwd deles, e quem apertou o botão
pediu por isso. O daemon faz o contrário: nunca para ninguém, e por isso só age
quando já não há ninguém trabalhando.
Quem mais está neste arquivo
Sincronizar não resolve duas pessoas dentro do mesmo arquivo ao mesmo tempo — só duas pessoas resolvem. O que dá para fazer é dizer cedo, enquanto ainda é uma conversa e não um merge.
O header mostra um chip quando outra aba do mesmo repositório está mexendo nos mesmos arquivos, e o tooltip nomeia a aba e os arquivos. Conta o que foi commitado na branch e o que ainda está solto no worktree: um arquivo já commitado colide tão forte quanto um que está no meio da edição.
Os dois lados são avisados. Aviso que só uma das duas pessoas vê começa discussão, não conversa.
A conta é barata porque todos os worktrees estão no mesmo disco sob um único banco de objetos: dois comandos git por aba viva, refeitos nos mesmos momentos em que a sincronização é verificada.
Duas camadas, e só duas
O trilho lista repositórios. A tira de abas lista as worktrees de um deles. Uma aba é o conjunto todo: worktree, branch, agente, terminal, arquivos e diff.
Não há uma terceira camada de branches dentro da aba, e o motivo é git puro: um worktree tem uma branch por vez. "Várias branches dentro de um worktree" seria checkout serial — uma viva de cada vez — e reintroduziria um nível abaixo o mesmo problema que a estrutura existe para eliminar. Worktree já é a branch; é o que torna duas branches paralelas em vez de alternadas.
Para trabalhar empilhado, abra outra aba escolhendo a branch da vizinha como base: o daemon de sincronia traz os commits dela sozinho, e as duas seguem em paralelo.
A aba nasce com o nome que você deu. O lápis na aba aberta renomeia no
lugar — e duplo clique também, para quem já sabe. Enter ou sair do campo
salva, Escape descarta, e nome em branco mantém o antigo em vez de deixar uma
aba sem nome. O campo é do tamanho do que está escrito, então a aba não pula nem
abre buraco enquanto se digita. Renomear muda só o rótulo: o nome da branch
é o que ela recebeu ao nascer.
O lápis aparece só na aba aberta: em todas seriam quatro elementos por aba disputando espaço com os nomes, e ali ele fica onde o olho já está.
O header da aba
A coluna do chat abre com duas faixas:
| faixa | altura | conteúdo |
|---|---|---|
| 1 | 64px fixos | ● nome da aba · repositório · branch · caminho do worktree |
| 2 | a das abas | as worktrees deste repositório (duplo clique renomeia) e os arquivos abertos — com + para abrir outra aba |
A segunda faixa acompanha as abas em vez de ocupar outros 64px, então não sobra espaço morto acima delas. Só a faixa 1 tem altura fixa.
As opções da aba ficam no canto superior direito da conversa, posicionadas de forma absoluta: não reservam espaço, o thread ocupa a altura inteira da coluna e elas ficam por cima.
À esquerda da barra fica o seletor de branch: qual branch este worktree
está segurando. Trocar move o checkout — os agentes são parados antes, porque
eles têm um cwd cujo conteúdo está prestes a mudar, e um turno que começa numa
branch e termina em outra é um bug que ninguém consegue ler.
Uma branch que outro worktree já segura não aparece: o git recusa a mesma
branch em dois worktrees, e linha que não dá para escolher é ruído num seletor.
O backend ainda a reporta, com taken_by, para quem precise saber. Quem decide se a troca é segura é o
próprio git switch — ele carrega mudanças limpas junto e recusa quando elas
seriam perdidas. Reimplementar esse julgamento aqui seria só uma segunda cópia,
pior, dele.
Não ocupar espaço é o que criava o encavalamento — o grupo tem 355px e a coluna
de leitura tem 848px centralizados, então em qualquer largura ele cobria parte
do texto. Duas coisas resolvem, sem devolver espaço ao layout: a conversa
reserva --wt-floating-clearance (76px) no topo, para nenhuma mensagem
começar debaixo dos botões, e a barra desenha um fade sob si — sólido na
altura dos botões, esvaindo no fim — para o que rola por baixo ler como
passando sob vidro em vez de bater neles. Sem cartão nem moldura própria
— os dois dropdowns (provedor e modo de permissão) são bare, do mesmo jeito.
Os três ícones à direita são uma escada, e o ícone diz qual degrau:
| ícone | ação | o que acontece |
|---|---|---|
| caixa de arquivo | Arquivar / Reabrir | para o agente; worktree e branch ficam. Reabrir é o caminho de volta que o arquivamento promete — só possível enquanto o diretório existir |
| pasta com X | Apagar o worktree do disco | git worktree remove --force. A aba passa a arquivada junto: sem diretório não há onde o agente rodar, e a caixa de confirmação diz isso |
| lixeira | Excluir a aba | tira a aba e o histórico do Tentacruel, e apaga o worktree |
Nenhum deles é um menu de reticências — ··· promete "mais opções", e usá-lo
para apagar diretório era mentira de ícone.
Só o chat tem as duas. O trilho abre com a marca e o painel com as abas Arquivos / Diff / Setup, ambos em 64px.
O rodapé segue a mesma régua: o trilho fecha com dois quadrados de 64px — nova
aba e Configurações — e o painel com a linha de commit/push, ambos em 64px, então a borda inferior
atravessa as colunas sem degrau. O estado da branch (↑1, worktree limpo)
subiu para o topo do painel, ao lado do diff que ele descreve.
A altura é fixa em 64px, sem escalar com a densidade, e vive só em
--wt-header-height. O rail tem 64px de largura, também fixa
(--wt-rail-width), então o canto superior esquerdo é um quadrado de 64×64.
Cada entrada do rail é um quadrado de 46px (--wt-rail-item-size) com o
tile do repositório de 38px (--wt-rail-avatar-size) centralizado nele —
4px de folga nos quatro lados. Os dois são pixels fixos, não passos de
densidade: é um ícone num tamanho escolhido, não texto que acompanha a escala.
Barra de status da aba
Duas linhas abraçam o campo de mensagem, ambas sem fundo e na mesma coluna
dele, com dados reais do agente em execução. A de cima lê como uma frase —
Sessão: 13% - Contexto: 5% - Semana: 7% - Fable: 1% — e a porcentagem só
ganha cor quando a janela começa a encher:
| Campo | Onde | De onde vem |
|---|---|---|
| % de contexto | acima | client.get_context_usage(), medido contra o limiar de auto-compact — é ele que decide quando a conversa é cortada |
| cota da assinatura (5h), da semana e por modelo | acima | a leitura de /usage que o próprio Claude Code guarda em ~/.claude.json |
| usuário e plano | abaixo | oauthAccount do ~/.claude.json — nome, email, organização e plano (default_claude_max_20x → Max 20x) |
| worktree e branch | abaixo | a própria aba; clique copia |
Contexto e cota falam do turno que está prestes a ser enviado, então ficam encostados na caixa de texto. Usuário e caminho descrevem o ambiente, e ficam embaixo.
De onde saem as porcentagens de cota. O
RateLimitEventdo SDK entregautilizationcomonullpara a maioria das contas — sóstatuseresetsAt. Por esse canal, porcentagem não existe.Mas o
/usagedo Claude Code tem os números, e guarda a resposta em~/.claude.json, sobcachedUsageUtilization— o mesmo arquivo de onde o Tentacruel já lê o perfil. Entãoservices/agents/limits.pylê esse cache e, quando a leitura envelhece (5 min), rodaclaude --print /usagepara o próprio CLI atualizá-lo. O comando é resolvido localmente: não gasta token de modelo. ORateLimitEventdeixou de ser fonte e virou gatilho — ele avisa que a cota mexeu, e aí o número é relido.Nenhuma credencial passa pelo Tentacruel: quem fala com a Anthropic é o CLI, com o login que já estava lá. E toda leitura carrega
limits_fetched_at, que aparece no tooltip — número velho nunca se passa por número vivo.A atualização roda fora do caminho quente: uma tarefa em segundo plano que transmite pelo WebSocket quando termina, porque abrir o CLI leva ~4s e um turno não pode acabar 4 segundos mais tarde por causa de um enfeite.
A leitura da conta é só de exibição: nome, email, plano e organização. O Tentacruel não lê, não guarda e não transmite credencial nenhuma — é o ponto de dirigir o CLI do provedor em vez de usar API key.
Avatar do repositório
O trilho é só ícone — sem legenda embaixo, que quebrava o layout com nomes longos. Cada item é um repositório, e mostra:
- a logo do repositório, se ele tiver uma. A busca não exige nome exato:
qualquer imagem cujo nome contenha
favicon,icon,apple-touch-iconoulogoconta —trakk_favicon.jpegelogo_whitemode.pngsão encontrados. Diretórios varridos: raiz,public/,static/,app/,src/app/,assets/,src/assets/,.github/,docs/e afins — maispublic/,static/,app/,src/app/dentro de cada pasta de primeiro nível, o que cobre monorepos (front-web/public/favicon.ico,front-admin/src/app/icon.png). Desempate: diretório mais próximo da raiz →faviconantes delogo(um wordmark a 32px é ilegível) → nome exato antes de decorado (logo.svgganha delogo-dark.svg) →svg>png>ico>webp>jpg. Sem logo, cai na inicial do nome do repositório. - a inicial do repositório num chip no canto superior esquerdo, para quando duas logos se parecerem à distância de 32px.
- uma borda colorida derivada do nome do repositório, e o anel azul no ativo.
- uma bolinha pulsando no canto inferior quando algum agente daquele repositório está no meio de um turno. Qual aba é a tira que diz.
Repositório sem nenhuma aba aberta fica esmaecido. Passe o mouse para ver nome,
branch base, caminho do repositório, quantas abas estão abertas e o atalho ⌘n.
A busca é limitada de propósito: varrer a árvore inteira entraria em
node_modules. node_modules, dist, build, .venv e afins ficam de fora,
assim como arquivos acima de 2 MB e symlinks que apontam para fora do repo.
.tentacruelinclude
Worktree é checkout limpo: arquivos ignorados pelo git (.env e afins) não
existem nele. Crie um .tentacruelinclude na raiz do repo — um glob por linha,
# para comentário (um .wtmuxinclude de antes do rename continua sendo lido
quando o novo não existe):
# segredos e config local
.env
.env.local
config/*.local.json
certs
Cada match é copiado para o worktree novo preservando o caminho relativo.
Padrões absolutos (/etc/...), com .. ou apontando para .git são ignorados
por segurança.
.tentacruel/setup.sh
Se existir, roda dentro do worktree novo logo após a criação — para
npm install, uv sync, symlinks, o que for. A saída aparece ao vivo na aba
Setup. Variáveis disponíveis: TENTACRUEL_REPO, TENTACRUEL_WORKTREE, TENTACRUEL_SLUG,
TENTACRUEL_BRANCH.
#!/usr/bin/env bash
set -euo pipefail
echo "provisionando $TENTACRUEL_SLUG"
uv sync
(cd frontend && npm ci)
Se o script falhar, a aba continua utilizável e o erro fica visível no log.
Arquitetura
src/tentacruel/
config.py Settings (uma fonte para portas, caminhos, timeouts)
models.py SQLModel: Repo, Session, Message, TrailEvent
db.py engine + sessões do SQLite (WAL) + migração no boot
schemas.py TODO shape que cruza HTTP/WS — espelhado em types.ts
main.py app FastAPI + estáticos + fallback SPA
cli.py `tentacruel` (console script)
api/ rotas finas: repos.py, sessions.py, ws.py, deps.py
services/
agents/
base.py contrato AgentBackend + emissões normalizadas
claude.py ÚNICO lugar que fala com o Claude Agent SDK
account.py perfil logado no CLI (só exibição, nunca credencial)
limits.py cotas da assinatura: lê o cache do `/usage` do CLI
registry.py catálogo de provedores (um provider novo = uma entrada)
git.py ÚNICO lugar que roda o binário `git`
picker.py ÚNICO lugar que roda `osascript` (seletor de pasta nativo)
repo_icon.py encontra a logo/favicon do repo para o avatar do repositório
files.py navegação e leitura de arquivos do worktree
attachments.py o que o usuário anexa: onde guarda e como chega ao modelo
transcribe.py ÚNICO lugar que chama um transcritor de voz
settings_store.py preferências do usuário (uma chave, um schema)
shells.py catálogo de shells (um shell novo = uma entrada)
terminal.py um terminal por aba: histórico, tamanho e morte
toolcheck.py detecta git/node/uv/npm e versões
installer.py instala um CLI de provedor, com streaming
proc.py ÚNICO lugar onde um subprocesso nasce e morre
wire.py ÚNICO lugar que monta caminho para cruzar o fio
fs.py ÚNICO lugar que decide como bytes e nomes vão ao disco
launcher.py o atalho do sistema (.app, .lnk)
platform/ ÚNICO lugar que sabe em que sistema isto roda
base.py Protocol PlatformSupport + Capability + PlatformMap
registry.py catálogo (um sistema novo = uma entrada)
posix.py o que macOS e Linux respondem igual
darwin.py osascript, Keychain, bundle .app
windows.py Job Object, DPAPI, PowerShell, WebView2, ConPTY
worktree.py ciclo de vida do worktree (sem FastAPI/DB/SDK)
agent.py ÚNICO lugar que fala com o Claude Agent SDK
tool_summary.py "editou src/x.py" — uma definição só
manager.py orquestra DB + worktree + agente + broadcast
repo.py registro de repositórios
hub.py fan-out de WebSocket
frontend/src/
styles/tokens.css ÚNICA fonte de cor, raio, tipografia, densidade
components/ui/ primitivos — o único lugar que desenha
components/ compostos do domínio (só primitivos + layout)
pages/ composição e estado
lib/ cn, api, ws, types, diff, format, terminalTheme
state/sessions.tsx estado global + sockets
tests/ pytest (worktree service + API + WebSocket)
Regras estruturais (as mesmas dos dois lados):
Estas regras são teste, e não convenção: tests/test_structure.py caminha
a AST do pacote e falha nomeando o módulo dono. Foi por serem só prosa que
quatro lugares passaram a montar caminho de fio à mão.
- Nenhum comando
gitfora deservices/git.py. - Todo subprocesso nasce e morre em
services/proc.py, e é derrubado por árvore inteira. Matar só o filho direto não basta: um neto (npm installdisparado pelosetup.sh, um credential helper do git) herda o pipe de stdout e mantém ocommunicate()do pai bloqueado — o timeout não serviria para nada. Como se derruba uma árvore é da plataforma: sessão própria no POSIX, Job Object no Windows. O nascimento entrou junto com a morte porque no Windows o cabo de desligar é criado no spawn. - Nada que saiba de sistema operacional fora de
services/platform/. Com uma exceção que é regra e não descuido: normalização de caminho e escrita em disco não despacham por sistema — são sempre a forma portátil, emservices/wire.pyeservices/fs.py, para que a suíte de uma máquina prove a outra. - Nenhum pseudoterminal aberto fora de
services/platform/. Abrir um pty é a definição de algoritmo diferente por sistema —openptymaislogin_ttyno POSIX, ConPTY no Windows — e quem quer um terminal pede aservices/terminal.py, que é dono da política: qual shell, quanto de histórico, e quando matar. - Nenhuma chamada a SDK de IA fora de
services/agents/<provider>.py. O resto do app só conhece as emissões normalizadas deagents/base.py. services/worktree.pynão importa FastAPI, SQLModel nem o SDK — só recebe caminhos e devolve dataclasses. É o que permite que, na v1.1, uma tool custom do SDK (create_worktree_session) chamecreate_worktree()direto e deixe o próprio agente abrir uma aba irmã. Não implementado no MVP, mas não bloqueado.- Todo shape do backend nasce em
schemas.pye é espelhado emfrontend/src/lib/types.ts. O OpenAPI fica em/docs.
Endpoints
| Método | Rota | O quê |
|---|---|---|
GET/POST |
/api/repos |
listar / registrar repositório |
POST |
/api/repos/browse |
abre o Finder nativo e devolve a pasta escolhida |
GET |
/api/repos/{id}/icon |
logo/favicon do repositório, usada no avatar do trilho |
GET |
/api/providers |
catálogo de provedores de IA, modelos e níveis de esforço |
GET/PATCH |
/api/settings |
preferências de interface (tema, densidade, layout, idioma) |
GET |
/api/system |
versão do Tentacruel + dependências, transcritores e shells da máquina |
PATCH |
/api/repos/{id} |
padrões do repositório herdados por abas novas |
WS |
/ws/install/{provider} |
instala o CLI de um provedor, com saída ao vivo |
DELETE |
/api/repos/{id} |
remover do Tentacruel (nada é apagado do disco) |
GET/POST |
/api/sessions |
listar / abrir aba |
GET/PATCH/DELETE |
/api/sessions/{id} |
histórico + log de setup / nome, provedor, modelo, esforço, permissão, visão / fechar a aba (remove_worktree, delete_branch) |
POST |
/api/sessions/adopt |
abrir um worktree que já existe |
GET |
/api/repos/{id}/worktrees |
worktrees no disco que nenhuma aba tem |
GET/POST |
/api/sessions/{id}/sync |
quantos commits atrás da base / trazer esses commits (merge) |
POST |
/api/sessions/{id}/sync/check |
conferir agora: a mesma volta do daemon, sem esperar o relógio |
GET |
/api/sessions/{id}/branches |
branches locais, marcando a atual e as presas em outro worktree |
POST |
/api/sessions/{id}/branch |
trocar a branch do worktree — 409 se o git recusar |
POST |
/api/sessions/{id}/interrupt |
interromper o turno em execução |
POST |
/api/sessions/{id}/archive |
arquivar (remove_worktree opcional) |
POST |
/api/sessions/{id}/unarchive |
reabrir — 409 se o worktree não estiver mais no disco |
GET |
/api/sessions/{id}/diff |
diff commitado + não commitado + status |
GET |
/api/sessions/{id}/usage |
uso da janela de contexto + cotas da assinatura |
GET |
/api/sessions/{id}/files |
filhos de um diretório do worktree (listagem preguiçosa) |
GET |
/api/sessions/{id}/files/index |
todo caminho que o git não ignora, de uma vez — o @ do campo |
GET |
/api/sessions/{id}/commands |
os comandos que o CLI resolveu para este worktree — o / do campo |
GET/PUT |
/api/sessions/{id}/file |
ler / salvar um arquivo do worktree |
POST |
/api/sessions/{id}/attachments |
guardar o que o usuário colou, arrastou ou escolheu |
POST |
/api/transcribe |
áudio → texto, pelo transcritor da máquina (nada é guardado) |
GET |
/api/sessions/{id}/file/raw |
os bytes de um arquivo, para o navegador desenhar |
POST |
/api/sessions/{id}/commit |
git add -A && git commit |
POST |
/api/sessions/{id}/push |
git push -u origin wt/<slug> |
WS |
/ws/sessions/{id} |
o canal de uma aba: histórico, streaming do agente e log de provisionamento |
WS |
/ws/events |
ciclo de vida das abas (mantém o trilho e a tira vivos) |
WS |
/ws/terminals/{id} |
o terminal de uma aba: teclado para dentro, tela para fora |
Estado
- Banco:
~/.tentacruel/tentacruel.db(SQLite/WAL).make resetapaga. Um~/.wtmux/de antes do rename é adotado no primeiroinit_db(), com o WAL consolidado antes do rename para não perder o histórico recente. - Migração:
init_db()roda a cada boot e é idempotente.create_allsó cria tabelas que faltam, então os passos seguintes cobrem o resto, nessa ordem: colunas novas (com backfill dos defaults), adoção dos times de repositório, o colapso dos chats em abas, remoção de colunas que o modelo não declara mais, a queda da tabelachate os índices que faltam. A remoção de coluna só acontece quando a órfã éNOT NULLsem default — a forma que trava todoINSERT, que é o caso demessage.chat_iddepois que a conversa voltou do chat para a aba. Como o SQLite não fazDROP COLUMNem coluna indexada ou usada por uma foreign key, a tabela é reconstruída — e é por isso que a tabelachatsó cai depois dela: enquantomessage.chat_idexistir, a chave estrangeira recusa oDROP TABLE. - Worktrees:
<repo>/.tentacruel/worktrees/<slug>. - Histórico do agente: gerenciado pelo próprio Claude Code; o Tentacruel guarda só o
agent_session_idpara oresume.
Notas do Agent SDK
Verificado contra claude-agent-sdk 0.2.142 e a documentação oficial em
https://code.claude.com/docs/en/agent-sdk/python (o domínio platform.claude.com
redireciona para lá). Diferenças em relação ao briefing original, com a doc/API
real prevalecendo:
| Ponto | Realidade na 0.2.142 |
|---|---|
| Classe de opções | ClaudeAgentOptions (o nome antigo ClaudeCodeOptions não existe mais) |
| Sessão persistente | ClaudeSDKClient.connect() sem prompt mantém o processo vivo; query() envia turnos e receive_messages() consome tudo |
session_id |
vem em SystemMessage(subtype="init").data["session_id"] e também em ResultMessage.session_id — SystemMessage tem subtype/data, não content: str |
resume |
ClaudeAgentOptions(resume=<uuid>) mantém o mesmo session_id (com fork_session=False), então o valor guardado continua válido entre reinícios |
| Streaming incremental | exige include_partial_messages=True; os deltas chegam como StreamEvent com event["type"] == "content_block_delta" e delta.type text_delta/thinking_delta |
permission_mode |
o SDK aceita 6 modos; o Tentacruel expõe os 3 do briefing (default, acceptEdits, plan) e troca em tempo real via client.set_permission_mode() |
| Interrupção | client.interrupt() |
| Subagentes | com forward_subagent_text=False (o padrão) só tool_use/tool_result de subagente viram mensagem — mas os deltas deles chegam mesmo assim, com parent_tool_use_id no StreamEvent. Mostrá-los era pendurar spinner sem bloco final para apagá-lo |
| Fluxos simultâneos | um turno tem um fluxo por subagente aberto. O id do streaming é por origem, não global: com um só, o message_start do subagente atropelava o do principal e nenhuma chave voltava a casar |
Outras decisões que divergem da letra do briefing:
- Tailwind v4 não usa
tailwind.config.js. O mapeamento token → utilitário vive em@theme inlinedentro defrontend/src/styles/globals.css, que é o equivalente v4 de "mapeados no config do Tailwind".tokens.csscontinua sendo a fonte única. - O diff mostra também o não commitado. O briefing pedia
git diff base...HEAD; como emacceptEditso agente edita sem commitar, o painel renderia vazio. A aba mostra as duas seções (não commitado — incluindo arquivos novos — e commitado), além dogit status --short. - O trilho é só avatar; nome, branch base e contagem de abas ficam no tooltip. Um trilho de ícones não comporta texto.
- ESLint foi adicionado manualmente. O template atual do Vite vem com
oxlint, que não suporta regras custom; a regra "template first" exigia ESLint.
Desenvolvimento
Além dos pré-requisitos de instalação, trabalhar no código pede Python 3.12+ e Node 20+ — este último só para compilar a interface, que num pacote instalado já vem pronta.
Rodar a partir do código
git clone <este-repo> Tentacruel && cd Tentacruel
make setup # uv sync + npm install
make dev # backend :8787 + Vite :5173 → abra http://localhost:5173
Modo "produção local" (tudo servido pelo FastAPI, uma porta só):
make start # build do frontend + servidor em http://localhost:8787
Outros alvos: make test, make lint, make build, make clean, make reset.
make help lista todos.
Trabalhar no app instalado, sem publicar
O .app roda o que estiver no ambiente da ferramenta, e ele pode apontar para
este repositório em vez de para uma versão do PyPI:
uv tool install --editable . --force
A partir daí, editar o código e reabrir o app basta — nenhuma publicação no
meio. Mudança de interface ainda pede make build, porque o que é servido é a
pasta compilada.
Para voltar ao pacote publicado:
uv tool install tentacruel --refresh --force
O --refresh não é enfeite: o uv guarda a listagem do índice, e sem ele um
uv tool upgrade responde "Nothing to upgrade" mesmo com a versão nova já
publicada. Aquele subcomando não aceita --refresh.
Testes
make test # ruff + pytest + tsc + eslint
uv run pytest -k worktree # só o worktree service
Cobertura atual: criação de worktree, .tentacruelinclude (incluindo
padrões que tentam escapar do repo), .tentacruel/setup.sh, colisão de slug
(nome repetido, branch preexistente, diretório órfão), remoção idempotente,
isolamento entre worktrees, diff commitado vs não commitado (incluindo arquivos
novos, apagados e grandes demais para inline), commit, push sem remote, resumos
de ferramenta, e o ciclo completo da API + WebSocket com um agente de mentira
(para não gastar tokens). O caminho com agente real é o teste manual acima.
O terminal é a exceção que não usa dublê: tests/test_platform.py abre um
pseudoterminal de verdade, roda um comando e confere a saída e o código de
saída. Cada máquina da matriz prova o mecanismo que só ela tem — o openpty no
macOS e no Linux, o ConPTY no Windows —, e um dos testes roda sob uvloop,
que é o laço que o app usa em produção: foi lá que o terminal quebrou uma vez,
com o laço padrão passando.
A matriz do CI é macos-latest, windows-latest e ubuntu-latest, com Python
3.12 e 3.13 em cada. O Linux entrou por último e por um motivo: linux.py
declara Capability.terminal e herda o pty do POSIX, mas as vagas de ubuntu do
workflow são do frontend, do serviço de coordenação e do wheel — nenhuma toca
este pytest, então a capacidade era declarada e nunca exercida.
Publicar uma versão
make dist # compila o frontend e monta o wheel COM a interface dentro
O alvo falha de propósito se a interface não estiver no pacote. Ela é artefato
de build e portanto está no .gitignore, e o hatchling monta a lista de
arquivos a partir do controle de versão — sem o artifacts no pyproject.toml,
o wheel instala sem UI e serve a página de "frontend não compilado". O erro só
aparece depois de instalar, que é o pior lugar para descobri-lo.
A versão vive só no pyproject.toml; o app a lê da metadata do pacote. Já
esteve escrita em três lugares, o que funciona até alguém editar um e esquecer
os outros dois.
Teste manual: os agentes estão mesmo isolados?
Critério de aceite nº 2. Leva ~3 minutos.
make deve registre um repo qualquer com pelo menos um commit.- Abra três abas:
alfa,beta,gama. - Mande instruções diferentes, uma em cada painel:
- alfa: "Crie um arquivo ALFA.txt com a palavra alfa. Não commite."
- beta: "Crie um arquivo BETA.txt com a palavra beta. Não commite."
- gama: "Liste os arquivos na raiz do projeto e me diga se existe ALFA.txt ou BETA.txt."
- No terminal, confira:
cd <seu-repo>
git status --short # vazio: o checkout principal está intacto
ls .tentacruel/worktrees/alfa # ALFA.txt presente
ls .tentacruel/worktrees/beta # BETA.txt presente
ls .tentacruel/worktrees/alfa/BETA.txt # No such file or directory ✅
ls .tentacruel/worktrees/beta/ALFA.txt # No such file or directory ✅
git worktree list # main + 3 worktrees
- O agente
gamadeve responder que não existe nenhum dos dois arquivos. - A aba Diff de cada uma mostra apenas o arquivo daquela aba.
Persistência e resume (critério 3): encerre o make dev (Ctrl+C), suba de
novo e reabra o browser. As três abas continuam na tira com o histórico
completo. Pergunte a alfa: "que arquivo você criou no turno anterior?" — ela
responde ALFA.txt, porque o Tentacruel guardou o agent_session_id e reconecta com
resume.
Remoção (critério 5): nos controles de gama, pasta-com-X → Apagar o worktree do disco.
ls .tentacruel/worktrees/ não lista mais gama, git worktree list também não, e
git branch --list "wt/*" continua mostrando wt/gama — arquivar nunca mexe na
branch.
Fechar a aba (o outro caminho): o × da aba beta abre a confirmação. Como
BETA.txt nunca foi commitado, ela avisa que há mudanças não commitadas e diz
que a branch permanece — e permanece mesmo: git branch --list "wt/*" ainda
mostra wt/beta.
Agora alfa, para ver o outro desfecho. Descarte o ALFA.txt (git -C .tentacruel/worktrees/alfa checkout -- . ou apague o arquivo) e feche a aba: sem
mudança solta e sem commit exclusivo, a branch sai, e o aviso da tela dizia isso
antes de você confirmar.
O critério é o trabalho, e não o pedido: commit que só existe naquela branch a
segura, e é isso que impede fechar uma aba de jogar trabalho fora. Repare que
"só existe aqui" é medido por conteúdo — commite em beta, mescle com
git merge --squash beta na main, e feche a aba: mesmo com o sha diferente, a
branch sai, porque o conteúdo já está lá.
Teste manual: o design system é mesmo fonte única?
Critério de aceite nº 7. Leva ~1 minuto. Detalhes em frontend/README.md.
- Abra
frontend/src/styles/tokens.csse troque uma linha:
--wt-primary: #5b6bf5; /* → #16a34a */
-
Com o
make devrodando, o app inteiro fica verde no reload: botão+, anel do avatar ativo, botão Enviar, ícones do assistente, badges, cabeçalho do hunk no diff, anel de foco. Nenhum outro arquivo foi tocado. -
Mesma ideia para comportamento: em
frontend/src/components/ui/Button.tsx, troquedefaultVariants: { variant: "secondary" }para"primary"— todo<Button>semvariantexplícito muda junto. -
Reverta e rode
cd frontend && npm run lint: as regrastentacruel/no-raw-elementsetentacruel/no-visual-classnamesfalham se alguma tela fora desrc/components/ui/usar um<button>cru ou uma classe visual comobg-[#1a1a1a]/px-[13px]. -
/dev/ui(http://localhost:5173/dev/ui) mostra o catálogo vivo de tokens e primitivos.
Vindo do wtmux
Sobre o nome. O produto é Tentacruel, e esse é também o identificador
técnico: o pacote Python (src/tentacruel/), o comando (tentacruel), o
diretório de dados (~/.tentacruel/) e a pasta de worktrees dentro de cada
repo (.tentacruel/worktrees/).
Antes, os quatro se chamavam wtmux — worktree multiplexer, no molde do
tmux, que multiplexa terminais. Instalações daquela época continuam
funcionando, e as duas metades são tratadas de formas diferentes porque o
risco não é o mesmo:
~/.wtmux/é movida no primeiro boot. É diretório nosso, e o WAL do SQLite é consolidado antes do rename — sem isso, o histórico mais recente seria descartado em silêncio, porque o SQLite acha o-walpelo nome do.db.<repo>/.wtmux/fica onde está. Aqueles worktrees estão registrados em.git/worktrees/e podem ter trabalho não commitado de agente dentro. O layout é detectado: repo que já tem.wtmux/mantém, repo novo nasce.tentacruel/..wtmuxincludetambém continua valendo — é arquivo seu, no seu repo.
Duas abreviações de worktree sobreviveram de propósito, e não têm relação
com o nome antigo: o prefixo de branch wt/ e os tokens CSS --wt-*.
Fora do escopo do MVP
Preview de dev server, multi-usuário/auth, empacotamento desktop, merge/PR pela UI (faça no terminal).
Provedores de IA além do Claude Code: a arquitetura está pronta e Codex,
OpenCode e Gemini já aparecem em Configurações → Provedores (detectáveis e
instaláveis). Falta escrever os adapters em services/agents/<id>.py — o que
exige os CLIs instalados para testar de verdade.
Próximo passo previsto (v1.1): tool custom create_worktree_session para o
próprio agente abrir uma aba irmã — a arquitetura já está preparada.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distributions
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tentacruel-0.2.1-py3-none-any.whl.
File metadata
- Download URL: tentacruel-0.2.1-py3-none-any.whl
- Upload date:
- Size: 2.0 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.10.7 {"installer":{"name":"uv","version":"0.10.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
02330e2733b7b83abc3a91d413e59ce9845e8975094916f79d6f9f1652c01635
|
|
| MD5 |
84ce7053aaa2c007731973304db055e5
|
|
| BLAKE2b-256 |
c0f7d960022bf4ba6c80e95f0202ef6823a5bde2e0f0f5c26014885ae330847e
|