Metadata-Version: 2.5
Name: tentacruel
Version: 0.2.0
Summary: Worktree multiplexer for Claude Code — one git worktree, one branch and one agent per tab
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: Operating System :: Microsoft :: Windows
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' or sys_platform == 'win32'
Requires-Dist: pywinpty>=3.0.5; sys_platform == 'win32'
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 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

```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 ou Windows** | as duas plataformas com suporte; no Linux ele sobe, mas sem seletor de pasta, janela própria nem cofre do sistema — o terminal embutido, esse funciona, porque o pty é do POSIX | — |
| **Claude Code instalado e logado** | o agente usa a sua assinatura; **não** há `ANTHROPIC_API_KEY` | `claude --version` e, se preciso, `claude` → `/login` |
| **git 2.30+** | worktrees são o produto inteiro | `git --version` |
| **[uv](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).

### No Windows

```powershell
uv tool install tentacruel
tentacruel
```

**O Claude Code no Windows tem de ser o instalador nativo, e não o do npm.**

```powershell
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](#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órios** → **Escolher
   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:

```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 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** (`1ª`, `2ª`…);
- os arquivos que ela tocou (`git status` + `git diff --name-only <base>...HEAD`);
- com quem ela colide, nomeada **pela branch** — quem lê é um agente, e ele não
  conhece aba nenhuma por número;
- o que o agente dela declarou.

**Dois autores, e de propósito.** O app escreve os fatos que mede; o agente
escreve a intenção dele em `.tentacruel/intencao.md`, dentro do próprio
worktree, e o quadro só lê. Se ele nunca escrever, o quadro mostra só os
fatos — que é justamente o motivo de os fatos não virem de uma promessa.

**A ordem de abertura é o critério de desempate.** Se uma aba **anterior** já
está tocando o arquivo que você precisa, o trabalho dela foi pedido antes:
espere ou faça outra coisa. Se a aba que o toca veio **depois**, siga. A regra é
arbitrária, mas é a mesma para os dois lados — e é isso que a torna utilizável.

O quadro chega ao agente pelo mesmo caminho da memória: uma linha `@` no
`CLAUDE.local.md` da raiz do repositório, que é import nativo do Claude Code e
não um pedido em prosa. As worktrees ficam abaixo da raiz, então herdam. O
arquivo está no `.git/info/exclude` — não aparece em `git status` nem em PR.

### Seletor de pasta

O botão **Escolher pasta** existe porque o browser não consegue
entregar um caminho absoluto — nem `webkitdirectory` nem a File System Access
API expõem isso. Como o backend roda na sua própria máquina, ele abre o diálogo
nativo do sistema e devolve o caminho.

Consequências:

- Hoje só no macOS, via `osascript` (`choose folder`). Onde não há seletor
  nativo o endpoint 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`, `low`…`max` | reinicia o agente e retoma via `resume`, então o contexto sobrevive |

São aliases, não ids fixos: `opus` sempre aponta para o Opus mais recente, então
a lista não envelhece. Um id completo (`claude-opus-4-5-20251101`) também é
aceito. `Padrão` = o que o seu CLI já estiver configurado para usar.

Rodar `haiku` numa aba de documentação e `opus` numa refatoração, em
paralelo no mesmo repo, é o caso de uso.

#### Autenticação: sempre a sua assinatura

**Nenhum provedor usa API key.** Cada backend dirige o CLI oficial do provedor,
que já carrega o seu login — hoje é o `claude` (assinatura do Claude Code). Um
provedor futuro seguirá a mesma regra: o CLI dele, o login dele, a sua conta.
O Tentacruel nunca lê, pede ou repassa `ANTHROPIC_API_KEY` ou equivalente.

O catálogo em `GET /api/providers` diz, por provedor, se o CLI está instalado
(`available`) e como logar (`auth_hint`, `install_hint`). O que não está
instalado aparece desabilitado na UI, com a instrução de instalação no tooltip.

#### Adicionando um provedor (o que falta)

A arquitetura está pronta, mas **só o Claude Code está implementado** — nenhum
outro CLI de agente estava instalado nesta máquina para eu construir e testar
contra. Para somar um:

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](#quem-mais-está-neste-arquivo).

O header mostra o estado, e são quatro. **O controle fica sempre na tela**, e
isso é conserto e não enfeite: enquanto ele só existia havendo o que trazer, o
estado normal — que é o estado quase sempre — não desenhava nada, e ninguém
tinha como saber que a sincronia existia.

| estado | o que significa |
|---|---|
| ⟳ *em dia* | nada a trazer. O tooltip diz com qual base e há quanto tempo foi conferido; clicar confere de novo, agora |
| `↓3 atrás` | há commits a trazer e um agente no meio de um turno. **O botão faz na mão**, parando os agentes antes |
| `aguardando` | há trabalho não commitado aqui. Desabilitado, porque parar o agente não resolveria — quem destrava é você |
| `em conflito` | o merge foi tentado e **desfeito inteiro** — e há um botão para pedir ao agente daquele worktree que resolva |

**Ele espera, não interrompe.** O caminho manual para os agentes antes de
mesclar, que é certo para algo que você apertou. Para algo que dispara sozinho,
matar um turno no meio por um merge de rotina não é.

**As duas esperas não são a mesma, e tratá-las como uma escondia a única que
tinha saída.** Worktree sujo e agente ocupado gravavam ambos `aguardando`, e a
tela desabilitava os dois — mas parar o agente é exatamente o que o caminho
manual faz, então o caso do agente ocupado era o caso que o botão resolve. O
resultado é que `↓N atrás` habilitado nunca chegava à tela de ninguém, e o
caminho manual, vivo e testado, não tinha como ser acionado. Hoje ocupado grava
`behind` e sujo grava `waiting`, que era o que o enum já previa.

Quatro coisas disparam a verificação, e duas delas já eram eventos que
existiam: o **fim de um turno**, um **commit** (que também avisa quem foi
cortado daquela branch — é isso que faz empilhamento funcionar sem remote
nenhum), um **relógio** a cada `TENTACRUEL_SYNC_INTERVAL` segundos (60 por
padrão, `0` desliga), e **você**, clicando no controle. O relógio existe porque
um `git pull` na `main` no seu terminal não produz evento nenhum que este app
consiga ouvir — e esse é o caso comum, não o exótico; o clique existe porque
quem acabou de dar esse pull não quer esperar cinquenta segundos para o app
notar. O clique roda a mesma volta do relógio: confere, traz se o caminho
estiver livre, e revarre as colisões e o `AGORA.md` de carona.

O número é `rev-list --left-right --count <base>...HEAD` — o mesmo cálculo que
alimenta o `↓` do painel.

**Merge, não rebase.** Rebase reescreveria commits que o agente talvez já tenha
enviado, e um rebase interrompido no meio deixa um worktree — em que um agente
está prestes a escrever — num estado que ninguém pediu. Merge ou acontece
inteiro, ou é desfeito inteiro.

**Conflito desfaz o merge.** Se der conflito, o `merge --abort` roda e os
arquivos que bateram são nomeados. Deixar marcadores de conflito num worktree
que um agente vai ler é entregar a ele um arquivo que parece código e não é.

**Mudanças não commitadas bloqueiam.** O merge reescreve arquivos; trabalho em
voo seria pego no meio. O botão fica desabilitado e o tooltip diz o porquê.

**No caminho manual, os agentes param antes** — pela mesma razão da troca de
branch: os arquivos vão mudar debaixo do `cwd` deles, e quem apertou o botão
pediu por isso. O daemon faz o contrário: nunca para ninguém, e por isso só age
quando já não há ninguém trabalhando.

### Quem mais está neste arquivo

Sincronizar não resolve duas pessoas dentro do mesmo arquivo ao mesmo tempo —
só duas pessoas resolvem. O que dá para fazer é dizer cedo, enquanto ainda é
uma conversa e não um merge.

O header mostra um chip quando outra aba do mesmo repositório está mexendo
nos mesmos arquivos, e o tooltip nomeia a aba e os arquivos. Conta o que foi
commitado na branch **e** o que ainda está solto no worktree: um arquivo já
commitado colide tão forte quanto um que está no meio da edição.

**Os dois lados são avisados.** Aviso que só uma das duas pessoas vê começa
discussão, não conversa.

A conta é barata porque todos os worktrees estão no mesmo disco sob um único
banco de objetos: dois comandos git por aba viva, refeitos nos mesmos
momentos em que a sincronização é verificada.

### Duas camadas, e só duas

O trilho lista **repositórios**. A tira de abas lista as **worktrees** de um
deles. Uma aba é o conjunto todo: worktree, branch, agente, terminal, arquivos e
diff.

Não há uma terceira camada de branches dentro da aba, e o motivo é git puro: um
worktree tem uma branch por vez. "Várias branches dentro de um worktree" seria
checkout serial — uma viva de cada vez — e reintroduziria um nível abaixo o
mesmo problema que a estrutura existe para eliminar. Worktree **já é** a branch;
é o que torna duas branches paralelas em vez de alternadas.

Para trabalhar empilhado, abra outra aba escolhendo a branch da vizinha como
base: o daemon de sincronia traz os commits dela sozinho, e as duas seguem em
paralelo.

A aba nasce com o nome que você deu. O **lápis na aba aberta** renomeia no
lugar — e duplo clique também, para quem já sabe. `Enter` ou sair do campo
salva, `Escape` descarta, e nome em branco mantém o antigo em vez de deixar uma
aba sem nome. O campo é do tamanho do que está escrito, então a aba não pula nem
abre buraco enquanto se digita. **Renomear muda só o rótulo:** o nome da branch
é o que ela recebeu ao nascer.

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

### O header da aba

A coluna do chat abre com duas faixas:

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

A segunda faixa **acompanha as abas** em vez de ocupar outros 64px, então não
sobra espaço morto acima delas. Só a faixa 1 tem altura fixa.

As opções da aba ficam no canto superior direito da conversa,
**posicionadas de forma absoluta**: não reservam espaço, o thread ocupa a
altura inteira da coluna e elas ficam por cima.

À **esquerda** da barra fica o seletor de branch: qual branch este worktree
está segurando. Trocar move o checkout — os agentes são parados antes, porque
eles têm um `cwd` cujo conteúdo está prestes a mudar, e um turno que começa numa
branch e termina em outra é um bug que ninguém consegue ler.

Uma branch que **outro worktree já segura** não aparece: o git recusa a mesma
branch em dois worktrees, e linha que não dá para escolher é ruído num seletor.
O backend ainda a reporta, com `taken_by`, para quem precise saber. Quem decide se a troca é segura é o
próprio `git switch` — ele carrega mudanças limpas junto e recusa quando elas
seriam perdidas. Reimplementar esse julgamento aqui seria só uma segunda cópia,
pior, dele.

Não ocupar espaço é o que criava o encavalamento — o grupo tem 355px e a coluna
de leitura tem 848px centralizados, então em qualquer largura ele cobria parte
do texto. Duas coisas resolvem, sem devolver espaço ao layout: a conversa
**reserva `--wt-floating-clearance` (76px) no topo**, para nenhuma mensagem
*começar* debaixo dos botões, e a barra desenha um **fade** sob si — sólido na
altura dos botões, esvaindo no fim — para o que rola por baixo ler como
passando sob vidro em vez de bater neles. Sem cartão nem moldura própria
— os dois dropdowns (provedor e modo de permissão) são `bare`, do mesmo jeito.

Os três ícones à direita são uma escada, e o ícone diz qual degrau:

| ícone | ação | o que acontece |
|---|---|---|
| caixa de arquivo | Arquivar / Reabrir | para o agente; worktree e branch ficam. Reabrir é o caminho de volta que o arquivamento promete — só possível enquanto o diretório existir |
| pasta com X | Apagar o worktree do disco | `git worktree remove --force`. A aba **passa a arquivada** junto: sem diretório não há onde o agente rodar, e a caixa de confirmação diz isso |
| lixeira | Excluir a aba | tira a aba e o histórico do Tentacruel, e apaga o worktree |

Nenhum deles é um menu de reticências — `···` promete "mais opções", e usá-lo
para apagar diretório era mentira de ícone.

Só o chat tem as duas. O trilho abre com a marca e o painel com as abas
Arquivos / Diff / Setup, ambos em 64px.

O rodapé segue a mesma régua: o trilho fecha com dois quadrados de 64px — nova
aba e Configurações — e o painel com a linha de commit/push, ambos em 64px, então a borda inferior
atravessa as colunas sem degrau. O estado da branch (`↑1`, `worktree limpo`)
subiu para o topo do painel, ao lado do diff que ele descreve.

A altura é **fixa em 64px**, sem escalar com a densidade, e vive só em
`--wt-header-height`. O rail tem **64px de largura**, também fixa
(`--wt-rail-width`), então o canto superior esquerdo é um quadrado de 64×64.

Cada entrada do rail é um quadrado de **46px** (`--wt-rail-item-size`) com o
tile do repositório de **38px** (`--wt-rail-avatar-size`) centralizado nele —
4px de folga nos quatro lados. Os dois são pixels fixos, não passos de
densidade: é um ícone num tamanho escolhido, não texto que acompanha a escala.

### Barra de status da aba

Duas linhas abraçam o campo de mensagem, ambas sem fundo e na mesma coluna
dele, com dados reais do agente em execução. A de cima lê como uma frase —
`Sessão: 13% - Contexto: 5% - Semana: 7% - Fable: 1%` — e a porcentagem só
ganha cor quando a janela começa a encher:

| Campo | Onde | De onde vem |
|---|---|---|
| **% de contexto** | acima | `client.get_context_usage()`, medido contra o limiar de auto-compact — é ele que decide quando a conversa é cortada |
| **cota da assinatura (5h), da semana e por modelo** | acima | a leitura de `/usage` que o próprio Claude Code guarda em `~/.claude.json` |
| **usuário e plano** | abaixo | `oauthAccount` do `~/.claude.json` — nome, email, organização e plano (`default_claude_max_20x` → `Max 20x`) |
| **worktree e branch** | abaixo | a própria aba; clique copia |

Contexto e cota falam do turno que está prestes a ser enviado, então ficam
encostados na caixa de texto. Usuário e caminho descrevem o ambiente, e ficam
embaixo.

> **De onde saem as porcentagens de cota.** O `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`.

```bash
#!/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_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 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

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

---

### 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:

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

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

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

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

```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 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](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 aba irmã — a arquitetura já está preparada.
