Skip to main content

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ãoANTHROPIC_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:

  • --reload não funciona no Windows pela mesma razão que o make dev não funciona: com reload ligado o uvicorn escolhe um event loop que não abre subprocesso, e aí toda chamada a git morre. O tentacruel normal 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, um node_modules estoura 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 o cmd.exe do %COMSPEC% é a garantia de que sempre há um. Uma armadilha vale ser dita: o Git Bash é derivado do git.exe instalado, e nunca de um bash.exe achado no PATH — o do PATH é o System32\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 consulta o índice de pacotes no máximo a cada 6 horas. 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

  1. Registrar um repo — Configurações → RepositóriosEscolher pasta (abre o diálogo nativo do sistema) ou cole o caminho absoluto. Qualquer subdiretório serve; o Tentacruel resolve a raiz.

  2. 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 → branch wt/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.

  3. 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.

  4. 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.

  5. 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 -d compara sha, e o fluxo mais comum não preserva sha nenhum: mesclar um PR com squash cria um commit diferente, e para o -d aquela branch nunca foi mesclada. O git cherry compara por conteúdo, e quando ele diz que tudo já foi aplicado, a branch sai. Sem isso, cada PR fechado deixava uma wt/* 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:

  1. Filesystem — worktrees são checkouts separados. O agente da aba A literalmente não tem os arquivos da aba B no seu diretório.
  2. cwd do SDK — o processo do Claude Code nasce dentro do worktree; caminhos relativos resolvem lá.
  3. Permissões — em acceptEdits, o CLI recusa escritas fora do diretório de trabalho (verificado: uma tentativa de escrever em ~/ foi negada).
  4. Instrução — o system_prompt_suffix diz ao agente que a branch é dele e proíbe git switch, git checkout <branch> e git 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/exclude resolve o git, mas não ferramentas que varrem o diretório por conta própria. Rodar pytest, ruff, eslint ou 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 exemplo norecursedirs = [".tentacruel"] no pytest, extend-exclude = [".tentacruel"] no ruff, ignores: [".tentacruel"] no ESLint. Se preferir os worktrees fora do repo, aponte TENTACRUEL_WORKSPACE_DIRNAME para outro nome ou ajuste worktrees_root() em services/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 (, …);
  • 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 devolve 501 e sobra o campo de texto — que continua aceitando um caminho absoluto colado.
  • Uma janela por vez: um segundo pedido recebe 409 enquanto 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, lowmax 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:

  1. Escreva services/agents/<provider>.py implementando o protocolo AgentBackend (start, send, interrupt, set_permission_mode, set_model, stop) e emitindo os dataclasses de agents/base.py.
  2. Adicione um ProviderSpec em services/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 de 0fr a 1fr, 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 sob width: auto nunca muda o valor — continua auto. Então ele fixa a largura antiga, solta até a nova e devolve para auto.

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ão node_modules, dist e afins não aparecem. .git nunca é 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 (ou Ctrl+S) salva, Tab insere 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.ts entram 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_20xMax 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 RateLimitEvent do SDK entrega utilization como null para a maioria das contas — só status e resetsAt. Por esse canal, porcentagem não existe.

Mas o /usage do Claude Code tem os números, e guarda a resposta em ~/.claude.json, sob cachedUsageUtilization — o mesmo arquivo de onde o Tentacruel já lê o perfil. Então services/agents/limits.py lê esse cache e, quando a leitura envelhece (5 min), roda claude --print /usage para o próprio CLI atualizá-lo. O comando é resolvido localmente: não gasta token de modelo. O RateLimitEvent deixou 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-icon ou logo conta — trakk_favicon.jpeg e logo_whitemode.png são encontrados. Diretórios varridos: raiz, public/, static/, app/, src/app/, assets/, src/assets/, .github/, docs/ e afins — mais public/, 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 → favicon antes de logo (um wordmark a 32px é ilegível) → nome exato antes de decorado (logo.svg ganha de logo-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 git fora de services/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 install disparado pelo setup.sh, um credential helper do git) herda o pipe de stdout e mantém o communicate() 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, em services/wire.py e services/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 — openpty mais login_tty no POSIX, ConPTY no Windows — e quem quer um terminal pede a services/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 de agents/base.py.
  • services/worktree.py nã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) chame create_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.py e é espelhado em frontend/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 reset apaga. Um ~/.wtmux/ de antes do rename é adotado no primeiro init_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_all só 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 tabela chat e os índices que faltam. A remoção de coluna só acontece quando a órfã é NOT NULL sem default — a forma que trava todo INSERT, que é o caso de message.chat_id depois que a conversa voltou do chat para a aba. Como o SQLite não faz DROP COLUMN em coluna indexada ou usada por uma foreign key, a tabela é reconstruída — e é por isso que a tabela chat só cai depois dela: enquanto message.chat_id existir, a chave estrangeira recusa o DROP TABLE.
  • Worktrees: <repo>/.tentacruel/worktrees/<slug>.
  • Histórico do agente: gerenciado pelo próprio Claude Code; o Tentacruel guarda só o agent_session_id para o resume.

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_idSystemMessage 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()

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 inline dentro de frontend/src/styles/globals.css, que é o equivalente v4 de "mapeados no config do Tailwind". tokens.css continua sendo a fonte única.
  • O diff mostra também o não commitado. O briefing pedia git diff base...HEAD; como em acceptEdits o 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 do git 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 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.

  1. make dev e registre um repo qualquer com pelo menos um commit.
  2. Abra três abas: alfa, beta, gama.
  3. 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."
  4. 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
  1. O agente gama deve responder que não existe nenhum dos dois arquivos.
  2. 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.

  1. Abra frontend/src/styles/tokens.css e troque uma linha:
--wt-primary: #5b6bf5;   /* → #16a34a */
  1. Com o make dev rodando, 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.

  2. Mesma ideia para comportamento: em frontend/src/components/ui/Button.tsx, troque defaultVariants: { variant: "secondary" } para "primary" — todo <Button> sem variant explícito muda junto.

  3. Reverta e rode cd frontend && npm run lint: as regras tentacruel/no-raw-elements e tentacruel/no-visual-classnames falham se alguma tela fora de src/components/ui/ usar um <button> cru ou uma classe visual como bg-[#1a1a1a] / px-[13px].

  4. /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 wtmuxworktree 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 -wal pelo 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/. .wtmuxinclude també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

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

tentacruel-0.2.0-py3-none-any.whl (2.0 MB view details)

Uploaded Python 3

File details

Details for the file tentacruel-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: tentacruel-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 2.0 MB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.13.12

File hashes

Hashes for tentacruel-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 dcfe8ef73c0ccd0b085976c12a1d414c7e0c62456ebead53ee772aec368a2bc0
MD5 67c83d70f4683893f10893ae703558d1
BLAKE2b-256 db9bd8c97e0b372514cec3c52d0f6762d01f49fdc36d8195b68ae7a3a3605a50

See more details on using hashes here.

Release history Release notifications | RSS feed

0.2.1

1 file

This release

0.2.0 This release

1 file

0.1.3

1 file

0.1.2

1 file

0.1.1

1 file

0.1.0

1 file

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page