Metadata-Version: 2.5
Name: tentacruel
Version: 0.1.3
Summary: Worktree multiplexer for Claude Code — one git worktree + one agent per chat session
Author: Zee Antunes
License-Expression: LicenseRef-Proprietary
License-File: LICENSE
Keywords: agents,ai,claude,claude-code,git-worktree,worktree
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Web Environment
Classifier: Intended Audience :: Developers
Classifier: License :: Other/Proprietary License
Classifier: Natural Language :: English
Classifier: Natural Language :: Portuguese (Brazilian)
Classifier: Natural Language :: Spanish
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Version Control :: Git
Requires-Python: >=3.12
Requires-Dist: claude-agent-sdk>=0.1.0
Requires-Dist: cryptography>=44.0.0
Requires-Dist: fastapi>=0.115.0
Requires-Dist: httpx>=0.27.0
Requires-Dist: pydantic-settings>=2.6.0
Requires-Dist: pydantic>=2.9.0
Requires-Dist: python-multipart>=0.0.12
Requires-Dist: pywebview>=5.3; sys_platform == 'darwin'
Requires-Dist: sqlmodel>=0.0.22
Requires-Dist: uvicorn[standard]>=0.32.0
Description-Content-Type: text/markdown

# Tentacruel

Multiplexador de worktrees para o Claude Code.

Cada sessão de chat = **um git worktree novo + uma branch nova + um agente Claude Code
com `cwd` fixado naquele worktree**. Três conversas no mesmo repositório nunca
enxergam os arquivos umas das outras, porque cada uma está fisicamente em um
diretório diferente.

App web local, single-user, sem auth. Roda em `localhost`.

```
┌──────┬─────────────────────────────┬──────────────┐
│ rail │        chat da sessão       │ Arqs | Diff  │
│ de   │  (streaming + input fixo)   │  + commit /  │
│ sess.│                             │    push      │
└──────┴─────────────────────────────┴──────────────┘
```

## Instalação

```bash
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

```bash
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:

```bash
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** | único alvo do MVP | — |
| **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](https://docs.astral.sh/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](#desenvolvimento).

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 e, quando há
versão mais nova, ela aparece em Configurações → Sobre. Toda falha é silenciosa:
não saber se há atualização não é problema que mereça ir para a tela de alguém.

`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** — ícone de pasta no rodapé do rail → **Escolher pasta
   no Finder** (abre o diálogo nativo do macOS) ou cole o caminho absoluto.
   Qualquer subdiretório serve; o Tentacruel resolve a raiz.
2. **Criar uma sessão** — botão `+` (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`.
3. **Conversar** — cada painel é um agente independente. Vários podem trabalhar
   em paralelo; a bolinha no rail pulsa enquanto o agente executa.
4. **Revisar** — aba *Diff* à direita mostra o que ainda não foi commitado e o
   que já está commitado na branch da sessão. Commit e push manuais ficam no
   rodapé do painel.
5. **Encerrar** — três passos, cada um um pouco mais longe que o anterior:
   *Arquivar* (para o agente, preserva tudo — e *Reabrir* traz de volta),
   *Apagar o worktree* (some com o diretório e, por consequência, arquiva a
   sessão) e *Excluir* (apaga a sessão do Tentacruel). **A branch sempre permanece
   no git.**

### Atalhos

| Atalho | Ação |
|---|---|
| `⌘1`…`⌘9` | troca para a n-ésima sessão ativa |
| `⌘N` | nova sessão |
| `⏎` / `⇧⏎` | 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 rail faz o mesmo.

---

## Como o isolamento funciona

Ao criar uma sessão o backend executa:

```bash
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 sessão A
   literalmente não tem os arquivos da sessão 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).

`.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ó.

### Seletor de pasta

O botão **Escolher pasta no Finder** 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 via `osascript` (`choose folder`) e devolve o `POSIX path`.

Consequências:

- Só funciona no macOS. Fora dele o endpoint devolve `501` e sobra o campo de
  texto.
- 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 sessões 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 sessão 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 sessão 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.

### 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 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` — que apaga o chat ou fecha o arquivo — dispara no fim.

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 de chat 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 sessão vizinha. A sessão 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. O header da sessão guarda o que é escolha de sessão
(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.

### 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 sessão foi excluída
sem apagar o diretório. A caixa de nova sessão 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 sessão para um agente ser apontado a eles. Ficam de fora
da lista os worktrees que já têm sessão 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](#quem-mais-está-neste-arquivo).

O header mostra o estado, e são quatro:

| estado | o que significa |
|---|---|
| *(nada)* | em dia com a base |
| `↓3 atrás` | há commits a trazer; o botão ainda faz na mão, parando os agentes antes |
| `aguardando` | há trabalho não commitado ou um agente no meio de um turno |
| `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 é — então a sessão fica
`aguardando` e sincroniza quando ficar quieta.

Três 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), e um
**relógio** a cada `TENTACRUEL_SYNC_INTERVAL` segundos (60 por padrão, `0`
desliga). 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 número é `rev-list --left-right --count <base>...HEAD` — o mesmo cálculo que
alimenta o `↓` do painel. O botão só aparece quando há algo a trazer.

**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 sessão do mesmo repositório está mexendo
nos mesmos arquivos, e o tooltip nomeia a sessão 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 sessão viva, refeitos nos mesmos
momentos em que a sincronização é verificada.

### Sessões e chats

Uma **sessão** é um worktree. Um **chat** é uma conversa com um agente rodando
dentro dele. Várias conversas podem dividir o mesmo worktree — como várias abas
de terminal no mesmo diretório — cada uma com **seu próprio agente, histórico,
modelo e modo de permissão**.

O isolamento continua onde sempre esteve: entre sessões, no filesystem. Dentro
de uma sessão, os chats compartilham os arquivos de propósito.

Chat nasce com nome automático (`Chat 2`, `Chat 3`…), que não diz nada quando
há quatro deles. 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.

O lápis aparece **só na aba aberta**: em todas seriam quatro elementos por chat
disputando espaço com os nomes, e ali ele fica onde o olho já está.

### O header da sessão

A coluna do chat abre com duas faixas:

| faixa | altura | conteúdo |
|---|---|---|
| 1 | 64px fixos | ● nome da sessão · repositório · branch · caminho do worktree |
| 2 | a das abas | as abas desta sessão — conversas (duplo clique renomeia) e arquivos abertos — com `+` para abrir outra conversa |

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 sessão 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` (52px) 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 sessão **passa a arquivada** junto: sem diretório não há onde o agente rodar, e a caixa de confirmação diz isso |
| lixeira | Excluir sessão | tira sessão e 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 rail e o painel mantêm uma faixa de 64px — o rail com
o botão de nova sessão, o painel com as abas Arquivos / Diff / Setup.

O rodapé segue a mesma régua: o rail fecha com o botão de 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 sessão

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 sessão (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 sessão; 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 da sessão

O rail é só ícone — sem legenda embaixo, que quebrava o layout com nomes longos.
Cada item 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 da sessão.
- **a inicial da sessão** num chip no canto superior esquerdo, porque várias
  sessões do mesmo repo mostrariam a mesma logo.
- **uma borda colorida** derivada do nome da sessão, e o anel azul na ativa.
- **o status** na bolinha do canto inferior (pulsando quando o agente trabalha).

Passe o mouse para ver nome, branch, caminho do worktree, status, repo 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`.

```bash
#!/usr/bin/env bash
set -euo pipefail
echo "provisionando $TENTACRUEL_SLUG"
uv sync
(cd frontend && npm ci)
```

Se o script falhar, a sessão 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, Chat, Message
  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 da sessão
    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)
    toolcheck.py      detecta git/node/uv/npm e versões
    installer.py      instala um CLI de provedor, com streaming
    proc.py           teardown de subprocesso por process group
    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
  state/sessions.tsx  estado global + sockets
tests/                pytest (worktree service + API + WebSocket)
```

Regras estruturais (as mesmas dos dois lados):

- Nenhum comando `git` fora de `services/git.py`.
- Todo subprocesso nasce com `start_new_session=True` e morre por *process
  group* (`services/proc.py`). 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.
- 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 sessão 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 da sessão |
| `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 detectadas na máquina |
| `PATCH` | `/api/repos/{id}` | padrões do repositório herdados por sessões 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 / criar sessão |
| `GET/PATCH/DELETE` | `/api/sessions/{id}` | detalhe + chats / renomear + provedor / excluir |
| `POST` | `/api/sessions/adopt` | abrir um worktree que já existe |
| `GET` | `/api/repos/{id}/worktrees` | worktrees no disco que nenhuma sessão tem |
| `GET/POST` | `/api/sessions/{id}/sync` | quantos commits atrás da base / trazer esses commits (merge) |
| `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 |
| `GET/POST` | `/api/sessions/{id}/chats` | listar / abrir outra conversa no mesmo worktree |
| `GET/PATCH/DELETE` | `/api/chats/{id}` | histórico / renomear, modelo, esforço, permissão / fechar |
| `POST` | `/api/chats/{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/chats/{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/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}` | log de provisionamento e lista de chats de um worktree |
| `WS` | `/ws/chats/{id}` | uma conversa, com streaming |
| `WS` | `/ws/events` | ciclo de vida das sessões (mantém o rail vivo) |

### 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 quatro passos cobrem o resto, nessa ordem:
  colunas novas (com backfill dos defaults), chats para sessões antigas
  (adotando as mensagens que ainda apontavam para a sessão), **remoção de
  colunas que o modelo não declara mais** e índices que faltam.
  A remoção só acontece quando a coluna órfã é `NOT NULL` sem default — a
  forma que trava todo `INSERT`, como `message.session_id` travou depois que a
  conversa saiu da sessão para o chat. Como o SQLite não faz `DROP COLUMN` em
  coluna indexada ou usada por uma foreign key, a tabela é reconstruída.
- 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_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()` |

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 rail mostra avatar + nome; branch e status ficam no tooltip.** Um rail de
  ícones não comporta um nome de branch.
- **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

```bash
git clone <este-repo> Tentacruel && cd Tentacruel
make setup        # uv sync + npm install
```

```bash
make dev          # backend :8787 + Vite :5173  → abra http://localhost:5173
```

Modo "produção local" (tudo servido pelo FastAPI, uma porta só):

```bash
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.

---

### Testes

```bash
make test          # ruff + pytest + tsc + eslint
uv run pytest -k worktree     # só o worktree service
```

Cobertura atual (427 testes): 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.

---

### Publicar uma versão

```bash
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.

1. `make dev` e registre um repo qualquer com pelo menos um commit.
2. Crie **três** sessões: `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:

```bash
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
```

5. O agente `gama` deve responder que **não** existe nenhum dos dois arquivos.
6. A aba *Diff* de cada sessão mostra apenas o arquivo daquela sessão.

**Persistência e resume (critério 3):** encerre o `make dev` (`Ctrl+C`), suba de
novo e reabra o browser. As três sessões continuam no rail 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`.

---

### Teste manual: o design system é mesmo fonte única?

Critério de aceite nº 7. Leva ~1 minuto. Detalhes em
[frontend/README.md](frontend/README.md).

1. Abra `frontend/src/styles/tokens.css` e troque **uma** linha:

```css
--wt-primary: #5b6bf5;   /* → #16a34a */
```

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

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

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

5. `/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 `-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 sessão irmã — a arquitetura já está preparada.
