O problema não é falta de ideia, é o que recebe luz agora — planeja antes de implementar, responde em número, avisa no desvio e não chama de pronto sem a saída…

<img src="assets/banner.svg" alt="rainforest-mind — memória de trabalho externa e radar de escopo" width="100%">
<img src="https://img.shields.io/badge/Claude_Code-plugin-2e8b57?style=flat-square" alt="Claude Code plugin"> <img src="https://img.shields.io/badge/vers%C3%A3o-1.53.1-1e5c3f?style=flat-square" alt="versão 1.53.1"> <img src="https://img.shields.io/badge/instala%C3%A7%C3%A3o-1_comando-6fcf97?style=flat-square" alt="uma instalação"> <img src="https://img.shields.io/badge/runtime-Node-9fd8ba?style=flat-square" alt="runtime Node">
O problema não é falta de ideia. É o que recebe luz agora.
Plugin de Claude Code que faz a sessão virar algo que dá pra ler e decidir. Quatro coisas, e nenhuma depende de você lembrar de ativar:
Duas dessas quatro têm trava que roda fora do modelo — comando com exit code, não instrução. Não dá pra argumentar com elas.
claude plugin marketplace add luisfmontes/rainforest-mind
claude plugin install rainforest-mind@rainforest-mind
Ou aponte --plugin-dir para a pasta do repo em desenvolvimento.
Requisito único: Node no PATH. Nada mais. Detalhe do runtime, dos gêmeos em Python e do orçamento de contexto em docs/runtime-e-orcamento.md.
Claude Code 2.1, patch 287 em diante, para a abertura inteira. Nessa versão o plugin entrega as regras e as elaborações como seção do system prompt (hooks/register.ts). Sem ela, ou no Codex, a abertura vem pelo hook com o núcleo, como antes. A mesma versão desenha a barra de sessão acima do prompt (hooks/mod.tsx): estado, tokens, custo, contexto, cache, subagentes, erros e o relógio ⏰ dos avisos de jornada (regra 8) e de janela parada (regra 17), mais o comando /painel. O painel pede o Claude Code 2.1, patch 292 em diante, a versão em que foi medido, no terminal, nas duas contas; no desktop o desenho não foi medido. Sem o mod, não há barra, painel nem aviso por relógio, e nada mais muda. Parte do painel é adaptada do terminal-desk (MIT); crédito em NOTICE.
Não precisa configurar nada para começar. Quando quiser foco próprio num repositório, crie .rainforest/FOCO.md nele — é só isso.
Você pede uma feature. Em vez de sair codificando, vem uma rodada numerada, cada pergunta já com a resposta recomendada:
❓ Q1 — Onde o token vive: sessão no servidor ou JWT no cliente? ➡️ Recomendo: sessão no servidor — você já tem Redis, e revogar JWT exige lista negra que é o mesmo trabalho com mais peças.
❓ Q2 — Expiração: 15 min com refresh, ou 8h fixas? ➡️ Recomendo: 8h fixas — refresh só se paga com múltiplos dispositivos, e não é o seu caso hoje.
E então para e espera. Você responde 1 ok, 2 não, usa 15 min — três palavras em vez de compor tudo do zero.
A regra que sustenta isso: descobrir fato é trabalho do assistente, nunca seu. O que está no arquivo, qual versão está instalada, o que o log diz — vira busca dele, não pergunta pra você.
E a emenda no fim do pedido não vira escopo em silêncio:
Você: faz a opção 2. Ah, e a gente podia colocar um cache nisso.
Sem o plugin: "Ótima ideia! Vou implementar a opção 2 com cache…"
Com o plugin: "Fechado: opção 2. Você adicionou o cache — entra no escopo agora ou planto?"
Uma mente-floresta — o termo é de Paula Prober, em Your Rainforest Mind — não sofre de falta de ideia. Sofre do contrário: tudo cresce ao mesmo tempo, em direções diferentes, e o que cresce junto disputa a mesma luz.
Por isso lista de tarefas não resolve: lista pressupõe escassez de tarefa, e aqui a tarefa sobra. O problema não é lembrar do que fazer — é decidir o que recebe luz agora, e proteger essa decisão do resto.
| Palavra | O que é |
|---|---|
| foco | a clareira onde você trabalha hoje — é contra ele, e só contra ele, que o desvio é medido |
| plantar | o que não pode crescer agora vai pro chão com contexto, vivo, em vez de morrer num "algum dia" |
| colher | volta quando chega a hora, e vira trabalho de verdade |
| descartar | sai da lista com motivo — a linha fica, porque ideia que sai sem rastro volta idêntica em três semanas |
| estação | tempo certo é restrição real, não desculpa de quem procrastina |
O resto do plugin não é botânico e não tenta ser: /foco, depurar e as travas se chamam pelo que fazem.
flowchart LR
A["arqueologia<br/>mapa.md"] -.->|"só se houver<br/>legado sem mapa"| B
B["brainstorm<br/>design.md"] --> P["plano<br/>plano.md"]
P --> E["executar<br/>agentes em paralelo"]
E --> R["revisar<br/>contexto zerado"]
R --> V["verificar<br/>roda o artefato"]
V --> F["fechar<br/>commit + limpeza"]
R -.->|"reprovado"| E
V -.->|"reprovado"| E
Cada estágio é uma skill invocável sozinha — dá para entrar no meio, que é o caso normal de quem retoma trabalho.
O que faz isso ser fluxo e não conselho: cada estágio abre rodando estado.cjs exigir, e esse comando sai com código 2 quando o anterior não fechou.
$ node scripts/estado.cjs exigir --slug 2026-08-11-exemplo --estagio revisar
RECUSADO: 'revisar' exige executar fechado(s).
executar: status=parcial
Rode o estagio 'executar' antes.
parcial e reprovado não fecham: "5 de 7 tarefas" para de virar "pronto" sem ninguém decidir isso.
Retomada é comando, não memória. Sessão nova roda estado.cjs proximo --slug <slug> e sabe onde parou. Design, plano e estado do fluxo são versionados de propósito — é por eles que outro dev pega a atividade no meio.
No /exit, o hook de SessionEnd hooks/titulo-sessao-end.cjs prefixa o título da sessão com o estado dos fluxos que ela tocou: [ok] quando todos fecharam, [aberto: <estágio>] quando algum ficou pela metade.
A busca do /resume casa contra o customTitle: digitar a palavra aberto filtra exatamente as sessões que ficaram pela metade — é por isso que o marcador é palavra, não símbolo.
O hook fica calado em três casos: reason diferente de prompt_input_exit (fechar o terminal na mão não dispara hook nenhum; claude -p emite other), sessão sem nenhum fluxo carimbado, e repositório que não usa o fluxo. Um nome dado com /rename tem precedência — o hook só prefixa, nunca substitui.
Esta é a que mais paga. Agente relata intenção, não resultado — e o relato é convincente exatamente quando está errado. Três cenas medidas, todas com suíte verde e relatório de sucesso completo:
a009b5b4e8f0d83e3ef4e3d8e7f3e4d8e7f3e4d8. Olhe o fim da string — e7f3e4d8 aparece duas vezes seguidas. É um hash inventado, e dava pra ver sem consultar nada.40 passed, exit 0 no lint — desligando as duas regras de lint que falhavam. O exit 0 era verdadeiro. Critério numérico não pega quem edita a régua.O que separou a entrega impecável da entrega com bug, medido em três sessões, não foi o modelo nem o isolamento:
Foi o briefing conter, ou não, o teste que falsificaria a entrega — com comando exato e saída exata esperada.
Suíte verde não é evidência. ✅ sem comando e saída colados não é verificação — e a saída colada tem que ser do objeto sobre o qual se está afirmando.
Regra escrita não alcança o modo de falha em que o agente leu a regra e errou mesmo assim.
Enquanto o veredito de uma checagem for redigido pelo mesmo agente que ela deveria travar, ela não trava nada. Exit code não se argumenta.
As baterias dos gates rodam em Windows + Git Bash (ambiente do CI: runs-on: windows-latest); Linux e macOS não são medidos. Cada gate declara seu modelo de ameaça nos docblocks.
Hook (PreToolUse, exit 2) | Barra | |||
|---|---|---|---|---|
gate-worktree.cjs | escrita de subagente fora de worktree linkado; git checkout/switch/reset com outra sessão no mesmo diretório | |||
gate-staging-total.cjs | git add com caminho total (-A, ., *…) e git commit -a | |||
gate-publicacao-destino.cjs | escrita de dado sensível (JID, telefone, e-mail, credencial) em arquivo rastreado | |||
gate-repo-alheio.cjs | escrita cujo destino está dentro de outro repositório git | |||
gate-fechar-issue.cjs | gh issue close direto, e closes #N em PR sem comentário de evidência marcado. Corpo de heredoc é dado, não estrutura de comando: cat > x.md <<'EOF' com prosa ((x). Ele sobe., $(x), crase) passa; o corpo vira comando quando a linha do heredoc tem um interpretador (bash <<EOF, `cat <<EOF \ | bash, (sh) <<EOF, eval, source, pwsh…), e o texto dele é sempre varrido pelos padrões diretos (gh issue close 12` literal no corpo barra, como antes) | ||
gate-mensagem-commit.cjs | git commit com assunto acima de 72 colunas ou terminando em ponto; sem corpo quando o stage passa de 3 arquivos ou 150 linhas; e mensagem que o hook não consegue ler (-F -, heredoc, git commit pelado — fechando merge, -F .git/MERGE_MSG) | |||
gate-verificador-staged.cjs | git commit cujo conteúdo staged o verificador do repositório reprova — descoberta nesta ordem: chave "verificador-staged" em .rainforest/config.json; senão `scripts/check-personal-data.py | .cjs | .sh | .js; senão scripts/conferir-publicacao.cjs. Materializa os blobs (git show :<caminho>`) numa pasta temporária e chama o verificador com esses caminhos; a saída dele vai no stderr. Repo sem verificador passa |
gate-subagente-sem-gh.cjs | dentro de subagente, gh que escreve no GitHub (issue close/comment/edit/create…, pr create/merge/comment…, release, gist, alias set, extension install, workflow run, run rerun, família que não é do gh — alias ou extensão —, api com método ≠ GET ou com campo) — no comando, atrás de wrapper, em bash -c (ilegível também nega), em heredoc e dentro do script executado, que é lido antes de rodar (bash/sh/source/python3/node/pwsh, ./x.sh, ./x.ps1, bash < x.sh, `cat x.sh \ | bash), e o código inline de node -e, python -c, pwsh -Command e cmd /c. Isenta só bateria testa-* rastreada pelo git; scripts/fechar-issue.cjs é negado pelo nome. A janela principal passa sempre. Toggle subagente-sem-gh` | ||
gate-subagente-sem-instalar.cjs | dentro de subagente, instalação de pacote (npm/pnpm/yarn/bun/pip/pipx/poetry/uv/cargo/go/gem/brew/apt/choco/scoop/winget, python -m pip, npx -y, Install-Module…) — também atrás de bash -c, sudo, env, FOO=1 e no PowerShell, decidido pelo subcomando (npm test, yarn build passam) —, e definir RAINFOREST_GATE_OFF — a chave de desligar gate não é do subagente. Não honra as próprias saídas de emergência. Toggle subagente-sem-instalar | |||
portaria.cjs | despacho de subagente não declarado em .rainforest/agentes.json, ou sem isolation: "worktree" quando ele escreve; agente que escreve (escreve: true) fora dos estagios dele, sem leve na branch, em repo com fluxo | |||
aviso-fluxo.cjs (PreToolUse em Edit/Write/MultiEdit) | edição de arquivo de código fora de docs/ sem fluxo aberto nem leve na branch, nos trilhos rainforest e protheus. A mensagem dá as duas saídas: abrir o fluxo (/rainforest-mind:brainstorm, ou /protheus:trabalhar) ou node <plugin>/scripts/estado.cjs leve --motivo "...". Chave aviso-fluxo do config desliga |
Um hook avisa sem barrar (exit 0 com additionalContext): idioma-session-start.cjs (SessionStart na compactação) reinjeta o idioma da chave idioma do config, que escorregava depois de compactar.
Fora da tabela porque o mecanismo é outro (Stop, exit 0 com {"decision":"block"}, e opt-in pela chave gate-review-codex): gate-review-codex.cjs barra encerrar o turno sem o revisor em Codex dizer ALLOW sobre a última resposta; Codex indisponível, resposta irreconhecível ou transcript que o hook não consegue ler bloqueiam com motivo (falha fechada). Só a chave desligada e o stop_hook_active do turno seguinte liberam sem perguntar. Codex sem cota bloqueia dizendo isso, com a hora de retorno (o despacho sai 75 e escreve codex sem cota: ...).
Fora da tabela, mesmo mecanismo (Stop, exit 2 com mensagem e ligado por padrão, desligável pela chave gate-turno-prometido em .rainforest/config.json): gate-turno-prometido.cjs barra o turno que prometeu despacho futuro ou em andamento (frases do design como "vou despachar", "despachando") e o turno não tem tool_use de Agent, Task, SendMessage nem Workflow; também barra turno que diz estar esperando máquina ("CI rodando", "aguardando build") e não tem Bash ou PowerShell com run_in_background === true, Monitor, nem ScheduleWakeup. Passado ("despachei") e negação ("não vou despachar") não disparam. Promessa com execução imediata passa; stop_hook_active === true libera. Subagente (payload com agent_id) não é conferido. Payload ou transcrição ilegível saem 0 com aviso no stderr.
Valem em qualquer repo git da máquina, porque o hábito é que é o problema, não o repositório. Cada uma tem bateria própria — 849 casos rodando o hook de verdade contra repos git montados na hora (soma re-medida em 2026-09-14, com os casos de heredoc e do staging citado: 201 + 106 + 24 + 27 + 151 + 25 + 307 + 8 — a portaria agora soma os dez testa-portaria-*.cjs, que o varrer-baterias.sh roda direto desde a 1.24, 92 + 14 + 24 + 10 + 28 + 8 + 16 + 87 + 5 + 23 (o portoes conta 5 porque imprime portões, não casos); re-verificar: a última linha da bateria hooks/testa-<hook>.sh de cada linha da tabela).
→ O incidente de origem de cada trava, as saídas de emergência e a tabela de scripts com exit code: docs/travas-mecanicas.md
| Script | Validação | ||
|---|---|---|---|
scripts/estado.cjs iniciar | Recusa (exit 2) abrir fluxo no checkout principal fora da branch padrão — a pasta do repositório fica na main, trabalho nasce em worktree (regra 11, Issue #195). A mensagem traz a receita (git worktree add …, git checkout main); exigir no mesmo estado só avisa. Clone dedicado a uma frente declara "principal-livre": true em .rainforest/config.json; em CI (CI/GITHUB_ACTIONS) não vale | ||
scripts/recibo.cjs | Congela identidade do entregável com sha256 + bytes; chamado pelo fechar quando plano declara entregaveis (opt-in, sem manifesto sai exit 0). Obriga nao_provado listado — recibo que alega provar tudo é suspeito. Re-executa portões com --reverificar se docs/rainforest/portoes/<slug>.md existe. Grava atomicamente em .rainforest/colheita/<slug>-recibo.json (fora do git). mostrar <slug> imprime; conferir <slug> recalcula hash e compara. | ||
scripts/conferir-duplicacao.cjs | Dois arquivos byte a byte iguais fora de fixtures/, node_modules/, .git/ e .claude/worktrees/ → exit 2 com os dois caminhos na mesma linha; --funcoes inventaria nomes repetidos entre scripts/*.cjs (exit 0, é inventário). Chamado pelo conferir-publicacao.cjs --commit e pelo /saude | ||
scripts/conferir-publicacao.cjs --commit <rev> ou a..b | Varre o conteúdo commitado, não o disco: dado sensível que já saiu do arquivo mas ficou no histórico é achado; disco ≠ commit vira diverge-do-commit. Exit 2 achado, 69 quando o ambiente impede | ||
scripts/conferir-glossario.cjs | Confere o GLOSSARIO.md da raiz do repo e recusa verbete com campo obrigatório faltando (Definição, Onde mora, Cenário), termo duplicado ou linha injetada acima de 900 B: exit 1, cada defeito nomeado como GLOSSARIO.md:<linha>. --exigir torna a ausência do arquivo defeito; --caminhos confere caminho em crase. A CI roda com --exigir --caminhos (.github/workflows/baterias.yml) | ||
scripts/estado.cjs marcar --json {"carimbos":…} | Grava por tarefa o hash de base em que ela foi aceita (iteracao cresce, nada se apaga); proximo e ler avisam quando esse hash não é ancestral do HEAD | ||
scripts/testa-teto-skills.sh | Nenhum SKILL.md acima de 500 linhas ou 16.384 B — a falha diz o que mover para references/ | ||
scripts/testa-mapa-regras.sh | Cada uma das 17 regras tem linha em ## Regra → trava de docs/travas-mecanicas.md (hook/script existente ou disciplina), e todo arquivo citado existe | ||
scripts/conferir-fluxo.cjs mutacoes --slug <slug> [--plano <arquivo>] | Roda a catraca de cada tarefa do plano que declara mutacao: (conferir-mutacao.cjs com os campos literais do bloco) e imprime uma linha por tarefa: vermelho, mutante sobreviveu ou pulada (<motivo>). Exit 1 se algum mutante sobreviveu, 0 se nenhum (ou tudo pulado), 2 se o plano não existe; --plano aponta o arquivo quando ele não está em docs/rainforest/planos/<slug>.md — é o que marcar verificar ok passa (Issue #192) | ||
| `scripts/eval-gatilho.sh trava | mutacao <skill> | baseline` | Eval de gatilho das skills (Issue #302), sob demanda e fora do CI — cada rodada é paga (~US$ 0,32 por caso). trava roda cada caso de evals/ 3 vezes com --ablation none (o disparo da skill conta no score) e aprova pela maioria; mutacao sabota a description da skill numa cópia e exige a trava vermelha; baseline roda with-without uma vez para o "acrescenta / peso morto". scripts/testa-eval-gatilho.sh prova o mecanismo com um claude falso, sem custo |
scripts/testa-sandbox-com-trap.sh | Guarda estática das baterias testa-*.sh que criam sandbox: mais de um mktemp -d no arquivo exige o idioma SANDBOXES=() + função que registra cada caixa + um único trap … EXIT que varre o array; um só mktemp -d basta com trap … EXIT simples; sandbox sem trap nenhum reprova. A própria guarda e testa-dependencias-de-bateria.sh ficam fora da varredura real (seus fixtures citam mktemp -d como texto); --autoteste prova a guarda com fixtures sintéticos (Issue #216) | ||
--confirmo em limpar-branches.cjs e limpar-worktrees.cjs --remover-sujo | Apagar branch e remover worktree sujo exigem a frase literal que o próprio script imprime (CONFIRMO apagar branches a,b), digitada por você e repassada verbatim — frase de outro alvo sai 2 e nada acontece | ||
exit 69 nao-verificavel: em conferir-entrega, conferir-mutacao, conferir-fluxo, conferir-ponte | Ambiente impediu a checagem (worktree sumiu, git fora do PATH, bateria que não executa): nem aprovação, nem reprovação, nem flaky — anuncia em uma linha e não redespacha (regra 14) |
O fluxo
divergir | Antes do brainstorm: seis frames isolados em paralelo mais um crítico cego. Devolve material, não decide |
arqueologia | Estágio 0, opcional: mapeia a fatia de legado que a demanda toca |
/brainstorm | Estágio 1: entrevista adversarial em árvore de decisão, grava o design |
plano | Estágio 2: tarefas tipadas, dependência declarada, critério falsificável |
executar | Estágio 3: despacha agentes, cada um em worktree isolado |
revisar | Estágio 4: contexto zerado, escopo fixado pelo diff — o relato não é fonte |
verificar | Estágio 5: roda o artefato real e cola a saída |
fechar | Estágio 6: commit, limpeza, PR como destino da branch |
limpar | Manutenção fora do fluxo: worktree e branch órfãos |
Do dia a dia
/foco | Estado da conversa; /foco <texto> declara novo foco | |
/ideia <texto> | Avalia contra o foco: dentro → entra; fora → planta com contexto | |
/issue | Abre Issue no repo em que você está, com o corpo escrito e a evidência colada | |
/feedback | Registra o que a sessão ensinou sobre o método | |
/saude | Só o que os checadores oficiais não sabem | |
/setup | Monta a pasta de dados, liga/desliga gates e fluxo | |
/semear | Propõe o que criar neste repo a partir do que ele já tropeçou | |
glossario | Propõe, migra ou lista verbetes do GLOSSARIO.md do repo; grava só depois de aprovação (skill) | |
/vigiar <contato> | Acompanha uma conversa de WhatsApp sem gastar token e acorda a sessão quando a pessoa escreve. Exige a integração integracao-whatsapp-mcp ligada e a bridge da conta de pé; o fluxo mora no repo whatsapp-mcp | |
/regua | Régua externa nomeada, builder contra crítico cego — para o que não tem teste. A Fase 0 destila a régua em 5-7 mecanismos conferíveis por olho em docs/rainforest/reguas/<slug>.md (o builder não os vê; o crítico sim) e faz o preflight de renderização, nomeando qual crítico ficaria cego. A régua é selada pelo commit que a adicionou: scripts/conferir-regua.cjs (validar antes de selar, conferir, mostrar) recusa régua alterada depois, e mostrar é o único caminho que a imprime. A partir da 2ª rodada, um segundo crítico cego compara o novo com o melhor guardado e decide keep/discard sozinho; as rodadas ficam num TSV versionado | |
/transferir | Leva a sessão atual para outro lugar. Sem argumento: uma thread Codex retomável por codex resume <id>, exige transfer-codex ligado no /setup. /transferir claude: copia a conversa inteira para a outra conta do Claude Code nesta máquina (~/.claude ↔ ~/.claude-personal), sem chave, e imprime o cd + claude --resume <id> para rodar lá; `--para pessoal\ | trabalho força o destino, --forcar` sobrescreve uma cópia que já existe. A origem nunca é apagada |
/ponte | Gera CLAUDE.md, AGENTS.md ou GEMINI.md (detalhe) | |
modo-dev | Escada YAGNI, causa raiz antes de remendo, rastreabilidade do diff | |
depurar | Constrói o loop de feedback antes de qualquer hipótese | |
analisar | Análise em notebook: uma pergunta por vez, revisão crítica do achado |
Agentes por função — a janela principal pensa; o agente executa.
executor · resolvedor-de-build · documentador | haiku, tarefa mecânica |
planejador · revisor · tester · depurador | sonnet, tarefa que exige julgamento |
arqueologo · auditor-de-seguranca | sonnet, executam skill própria |
O auditor-de-seguranca roda duas réguas OWASP (Top 10 2025 + API Security 2023) e uma Régua 3 de técnicas — referencias/reguas-tecnicas-ofensivas.md, 30 lentes de detecção defensiva derivadas do repositório SnailSploit/claude-red (MIT) e reescritas report-only — que afia as duas no nível de técnica de ataque.
Agentes em Claude ou Codex — cada agente tem um runtime: no manifesto (ausente = claude); a primeira linha do briefing pode ser Runtime: codex ou Runtime: claude para override (case-insensitive). Quando o usuário diz "faz no codex", "roda no codex" ou equivalente, o despacho põe Runtime: codex na primeira linha. Transporte via codex exec usa sandbox read-only ou workspace-write conforme escreve, modelo por codex-modelo-<haiku|sonnet|opus> no /setup (padrão do ~/.codex/config.toml). As skills review, adversarial-review e rescue do openai/codex-plugin-cc não ganharam comando aqui porque já existem como função neste plugin: revisor, segunda opinião
hooks/mod.tsx 1230 lines1// A barra de sessao e o pane /painel daqui sao adaptados do terminal-desk 0.2.1 (licenca
2// MIT, titular ClariSortAi); texto da licenca e origem em NOTICE, na raiz do plugin.
3// Entrada do mod: a abertura (register.ts, sem mudanca) mais a barra de sessao acima do
4// prompt (estado, tokens, custo, contexto, cache, subagentes, erros e o relogio) e o
5// comando /painel. A logica pura mora em ./painel-puro.mjs e ./relogio-puro.mjs; aqui so se
6// liga os eventos. O engine recusa `$` passado como argumento a qualquer funcao do arquivo,
7// entao `buscar` e `medir` recebem so valores e lambdas montadas no ponto de chamada de
8// cada hook. Toda leitura do mundo tem falha aberta: so a peca afetada some.
9import { atom, read, update } from 'claude-code'
10import type { Register } from 'claude-code'
11import type {
12 RainforestMindFaixaDados,
13 RainforestMindPainelAgente,
14 RainforestMindPainelDeixado,
15 RainforestMindPainelDeixadoOrigem,
16 RainforestMindPainelFatia,
17 RainforestMindPainelMapa,
18 RainforestMindPainelStats,
19 RainforestMindRelogioJornada,
20 RainforestMindRelogioSessoes,
21} from '../types'
22import { register as abertura } from './register.ts'
23import { CHECAR_MIN_FERRAMENTAS, deferimentos, lerResolvidosChecker, lerRespostaChecker, marcadoresEmArquivo, montarPromptChecker, perguntaDecisao, rascunhoFazAgora } from './deixado-puro.mjs'
24import { largura, cortar, semControle } from './faixa-puro.mjs'
25import { ESCRITORAS, escritaDe, mapaVazio, registrar, trocarCaminho } from './mapa-puro.mjs'
26import { cacheDe, compacto, dinheiro, fatias, figurasDaBarra, restante, ritmoPorMinuto, rotuloSubagente } from './painel-puro.mjs'
27import { avaliarRelogio, linhaRelogio, notaJornada } from './relogio-puro.mjs'
28
29type Fatia = RainforestMindPainelFatia
30
31const MIN = 60_000
32const HORA = 60 * MIN
33const MAX_AGENTES = 12
34
35const VAZIO: RainforestMindPainelStats = {
36 turnos: 0,
37 ferramentas: 0,
38 falhas: 0,
39 tokensNovos: 0,
40 tokensCacheLido: 0,
41 tokensCacheEscrito: 0,
42 tokensSaida: 0,
43 custoUsd: null,
44 ctxPct: null,
45 ctxTokens: null,
46 ctxJanela: null,
47 fatias: [],
48 carimbos: [],
49 agentes: [],
50 ultimaRequisicaoMs: 0,
51 modelo: null,
52 ttlMs: HORA,
53 medirDeNovo: false,
54}
55
56const faixaDados = atom({ plugin: 'rainforest-mind', key: 'faixaDados' } as const, null as RainforestMindFaixaDados | null)
57const painelStats = atom({ plugin: 'rainforest-mind', key: 'painelStats' } as const, VAZIO)
58const painelOculto = atom({ plugin: 'rainforest-mind', key: 'painelOculto' } as const, false)
59const painelMapa = atom({ plugin: 'rainforest-mind', key: 'painelMapa' } as const, mapaVazio() as RainforestMindPainelMapa)
60const relogioJornada = atom({ plugin: 'rainforest-mind', key: 'relogioJornada' } as const, null as RainforestMindRelogioJornada | null)
61const relogioSessoes = atom({ plugin: 'rainforest-mind', key: 'relogioSessoes' } as const, null as RainforestMindRelogioSessoes | null)
62const relogioNotaPendente = atom({ plugin: 'rainforest-mind', key: 'relogioNotaPendente' } as const, null as string | null)
63const relogioNotaEntregue = atom({ plugin: 'rainforest-mind', key: 'relogioNotaEntregue' } as const, null as string | null)
64
65// `PARADO` = nenhum checker em voo. O literal que desce o aviso mora so no `finally` da checagem,
66// a linha unica que a prova de mutacao troca.
67const PARADO = false
68const DEIXADO_VAZIO: RainforestMindPainelDeixado = { itens: [], proximo: 1, checando: PARADO, checar: true, pedido: '', ferramentas: [] }
69const painelDeixado = atom({ plugin: 'rainforest-mind', key: 'painelDeixado' } as const, DEIXADO_VAZIO)
70
71const deixadoInteiro = (d: RainforestMindPainelDeixado | null | undefined): RainforestMindPainelDeixado => ({ ...DEIXADO_VAZIO, ...(d ?? {}) })
72
73const inteiro = (s: RainforestMindPainelStats | null | undefined): RainforestMindPainelStats => ({ ...VAZIO, ...(s ?? {}) })
74
75type Io = {
76 rodar: (argv: string[], init: { cwd: string; env: Record<string, string>; timeoutMs: number }) => Promise<{ exitCode: number; stdout: string }>
77 cwd: () => Promise<string>
78 raiz: string
79}
80
81// Falha aberta: exit != 0, JSON invalido, timeout ou excecao devolvem null (apaga so os dados).
82async function buscar(io: Io): Promise<RainforestMindFaixaDados | null> {
83 try {
84 const cwd = await io.cwd()
85 const r = await io.rodar(
86 ['node', `${io.raiz}/scripts/faixa-dados.cjs`, '--cwd', cwd],
87 { cwd: io.raiz, env: { CLAUDE_PROJECT_DIR: cwd }, timeoutMs: 5000 },
88 )
89 return r.exitCode === 0 ? JSON.parse(r.stdout) : null
90 } catch {
91 return null
92 }
93}
94
95type IoRodar = { rodar: Io['rodar']; raiz: string }
96
97type IoAnotar = {
98 gravar: (f: (d: RainforestMindPainelDeixado) => RainforestMindPainelDeixado) => Promise<unknown>
99 avisar: (texto: string) => void
100}
101
102// Itens novos do "deixado para depois": o mesmo texto nao entra duas vezes, guarda os 40 mais
103// recentes e avisa com um toast so quando entrou algo. Recebe so valores e lambdas (o engine
104// recusa `$` como argumento).
105async function anotar(io: IoAnotar, achados: string[], origem: RainforestMindPainelDeixadoOrigem): Promise<void> {
106 if (achados.length === 0) return
107 let novos: string[] = []
108 await io.gravar(raw => {
109 const d = deixadoInteiro(raw)
110 novos = [...new Set(achados)].filter(texto => !d.itens.some(i => i.texto === texto))
111 if (novos.length === 0) return d
112 return {
113 ...d,
114 proximo: d.proximo + novos.length,
115 itens: [...d.itens, ...novos.map((texto, i) => ({ id: d.proximo + i, texto, origem, estado: 'aberto' as const }))].slice(-40),
116 }
117 })
118 if (novos.length > 0) io.avisar(`Deixado para depois: ${(novos[0] ?? '').slice(0, 80)}`)
119}
120
121// Falha aberta: exit != 0 (jornada.cjs exit 2 = sem linha), JSON invalido, timeout ou excecao
122// devolvem null (apaga so a leitura).
123async function rodarJson(io: IoRodar, argv: string[], timeoutMs: number, env: Record<string, string>): Promise<unknown> {
124 try {
125 const r = await io.rodar(argv, { cwd: io.raiz, env, timeoutMs })
126 return r.exitCode === 0 ? JSON.parse(r.stdout) : null
127 } catch {
128 return null
129 }
130}
131
132function jornadaDe(bruto: any): RainforestMindRelogioJornada | null {
133 const ultimo_ms = bruto ? Date.parse(bruto.ultimo) : NaN
134 if (!bruto || typeof bruto.efetiva_min !== 'number' || !Number.isFinite(ultimo_ms)) return null
135 return { efetiva_min: bruto.efetiva_min, ultimo_ms }
136}
137
138function sessoesDe(bruto: any): RainforestMindRelogioSessoes | null {
139 if (!bruto || typeof bruto.ociosidade_min !== 'number' || !Array.isArray(bruto.janelas)) return null
140 return { ociosidade_min: bruto.ociosidade_min, janelas: bruto.janelas }
141}
142
143// Linha do relogio para a barra; qualquer falha apaga so o relogio.
144function linhaDoRelogio(jornada: RainforestMindRelogioJornada | null, sessoes: RainforestMindRelogioSessoes | null, agora: number): string | null {
145 try {
146 return linhaRelogio(avaliarRelogio({ jornada, sessoes, agora })) as string | null
147 } catch {
148 return null
149 }
150}
151
152type IoMedir = {
153 usage: () => Promise<any>
154 gravar: (f: (s: RainforestMindPainelStats) => RainforestMindPainelStats) => Promise<unknown>
155 log: (texto: string) => void
156}
157
158// Contexto e custo pela estimativa local (`breakdown: 'summary'`; `full` chama a API de
159// contagem a cada turno). Falha aberta: o contexto some da barra, o resto fica.
160async function medir(io: IoMedir): Promise<void> {
161 let uso: any
162 try {
163 uso = await io.usage()
164 } catch (err) {
165 try {
166 io.log(`painel: contexto: ${String(err)}`)
167 } catch {
168 // sem log, sem problema
169 }
170 try {
171 await io.gravar(raw => ({ ...inteiro(raw), ctxPct: null, ctxTokens: null, ctxJanela: null, fatias: [], medirDeNovo: false }))
172 } catch {
173 // a medicao nunca quebra nada
174 }
175 return
176 }
177 try {
178 const contexto = uso?.context ?? {}
179 const detalhe = contexto.breakdown
180 const fatias = (Array.isArray(detalhe?.categories) ? detalhe.categories : [])
181 .filter((c: any) => c && typeof c.tokens === 'number' && c.tokens > 0)
182 .map((c: any) => ({ nome: String(c.name), tokens: c.tokens as number, tipo: String(c.kind) }))
183 const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : null)
184 await io.gravar(raw => {
185 const s = inteiro(raw)
186 return {
187 ...s,
188 custoUsd: num(uso?.cost?.usd) ?? s.custoUsd,
189 ctxPct: num(detalhe?.percentage) ?? num(contexto.percent),
190 ctxTokens: num(contexto.tokens) ?? num(detalhe?.totalTokens),
191 ctxJanela: num(detalhe?.rawMaxTokens) ?? num(contexto.window),
192 fatias,
193 medirDeNovo: false,
194 }
195 })
196 } catch {
197 // a medicao nunca quebra nada
198 }
199}
200
201// Subagentes: a linha de um agente nunca visto comeca em branco; acima do teto saem os
202// mais antigos ja encerrados (um em andamento nunca sai).
203function comAgente(agentes: RainforestMindPainelAgente[], id: string, at: number, mudar: (a: RainforestMindPainelAgente) => RainforestMindPainelAgente): RainforestMindPainelAgente[] {
204 const conhecido: RainforestMindPainelAgente = agentes.find(a => a.id === id) ?? {
205 id,
206 rotulo: `agente ${id.slice(0, 6)}`,
207 tipo: 'subagente',
208 modelo: null,
209 iniciadoEm: at,
210 fimEm: null,
211 vistoEm: at,
212 criadoAqui: false,
213 ferramentas: 0,
214 tokensLidos: 0,
215 tokensSaida: 0,
216 falhou: false,
217 }
218 const linhas = [...agentes.filter(a => a.id !== id), { ...mudar(conhecido), vistoEm: at }]
219 const sobra = linhas.filter(a => a.fimEm !== null).slice(0, Math.max(0, linhas.length - MAX_AGENTES))
220 return linhas.filter(a => !sobra.includes(a))
221}
222
223// Um agente que ninguem viu comecar nesta carga do mod pode nunca avisar que acabou: o
224// silencio de 2 min o encerra.
225const emAndamento = (a: RainforestMindPainelAgente, agora: number): boolean =>
226 a.fimEm === null && (a.criadoAqui || agora - a.vistoEm < 2 * MIN)
227
228const mmss = (ms: number): string => {
229 const s = Math.max(0, Math.floor(ms / 1000))
230 return String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0')
231}
232
233const COR = { ambar: '#ffb000', verde: '#00d67a', vermelho: '#ff4d4d', ciano: '#4dd2ff', cinza: '#808080' }
234const CORES_FATIA = ['#4dd2ff', '#00d67a', '#ffb000', '#c792ea', '#ff7eb6', '#7aa2f7', '#e0af68']
235const PANE = 'painel'
236const FATIAS_NA_LEGENDA = 7
237const AGENTES_NO_PANE = 6
238
239// O slug do fluxo comeca pela data de criacao; no pane so o nome conta.
240const slugSemData = (slug: string): string => slug.replace(/^\d{4}-\d{2}-\d{2}-/, '')
241
242const AJUDA = 'Subcomandos: esconder, mostrar, cache 5m|1h, checar ligar|desligar, limpar, erros [horas]. Sem argumento abre o painel.'
243// Ate tantas escritas esperam a vez do script do desvio; as demais entram no mapa sem veredito.
244const FILA_DESVIO_MAX = 50
245const VEREDITOS_DEFINITIVOS = new Set(['dentro', 'fora', 'isento'])
246
247export const register: Register = (on, options) => {
248 abertura(on, options)
249
250 // Desvio do plano: no maximo um spawn do script por vez. Escritas que chegam durante ele
251 // esperam em `filaDesvio`; `desvioPendente` (na fila ou rodando) e `desvioConsultado` (ja
252 // tem veredito) garantem que cada caminho roda uma vez so. Zerados a cada sessao.
253 const filaDesvio: { escrita: string; rodar: () => Promise<boolean> }[] = []
254 const desvioPendente = new Set<string>()
255 const desvioConsultado = new Set<string>()
256 // Relativo que o script ja devolveu para cada caminho absoluto: a falha aberta e a fila cheia
257 // registram com ele, para o mesmo arquivo nao aparecer duas vezes no mapa.
258 const relConhecido = new Map<string, string>()
259 let desvioRodando = false
260 const drenarDesvios = async (): Promise<void> => {
261 if (desvioRodando) return
262 desvioRodando = true
263 try {
264 for (let item = filaDesvio.shift(); item !== undefined; item = filaDesvio.shift()) {
265 try {
266 if (await item.rodar()) desvioConsultado.add(item.escrita)
267 } catch {
268 // o desvio nunca quebra a ferramenta
269 }
270 desvioPendente.delete(item.escrita)
271 }
272 } finally {
273 desvioRodando = false
274 }
275 }
276
277 // Timers do relogio e do tique da barra: ficam no escopo do register para o session.end e
278 // o proximo session.start cancelarem os da sessao anterior.
279 let timers: { cancel: () => void }[] = []
280 // Id da sessao que armou os timers: so o session.end dela cancela o relogio.
281 let armadoPor: string | null = null
282 // O tique da barra (1 s) so existe depois de alguma atividade (ferramenta, passo, subagente,
283 // turno) ou de um compact que pediu medicao nova, e se desarma quando nada mais conta: parado,
284 // nao ha timer nenhum.
285 let ativo = false
286 let medirPendente = false
287 let tiqueTimer: { cancel: () => void } | null = null
288 let armarTique: (() => void) | null = null
289 const acordar = () => {
290 ativo = true
291 try {
292 if (armarTique !== null) armarTique()
293 } catch {
294 // sem tique, a barra so se redesenha quando o estado muda
295 }
296 }
297 const cancelarRelogio = () => {
298 armadoPor = null
299 armarTique = null
300 if (tiqueTimer !== null) {
301 try {
302 tiqueTimer.cancel()
303 } catch {
304 // timer ja encerrado
305 }
306 tiqueTimer = null
307 }
308 ativo = false
309 const velhos = timers
310 timers = []
311 for (const t of velhos) {
312 try {
313 t.cancel()
314 } catch {
315 // timer ja encerrado
316 }
317 }
318 }
319
320 on('session.start', async ($, e, next) => {
321 try {
322 await $.command.register({
323 name: 'painel',
324 description: 'Painel da sessao: /painel abre o pane; esconder, mostrar, cache 5m|1h',
325 })
326 } catch {
327 // sem comando, a barra segue
328 }
329 // claude -p, SDK e subagente nao desenham a barra: sem timer, sem ler os fluxos do painel,
330 // e sem cancelar o relogio da sessao interativa que compartilha esta instancia do mod.
331 if (!e.isInteractive) return next(e)
332 // Depois do guard: sessao nao interativa (claude -p, SDK) que compartilha a instancia nao
333 // zera o cache da interativa.
334 desvioConsultado.clear()
335 relConhecido.clear()
336 try {
337 const dados = await buscar({
338 rodar: (argv, init) => $.process.run(argv, init),
339 cwd: () => $.session.cwd(),
340 raiz: $.plugin.root,
341 })
342 await update($, faixaDados, () => dados)
343 } catch {
344 // a barra nunca quebra a abertura da sessao
345 }
346 cancelarRelogio()
347 try {
348 const id = await $.session.id()
349 armadoPor = id
350 const cwd = await $.session.cwd()
351 const raiz = $.plugin.root
352 const io = { rodar: (argv: string[], init: { cwd: string; env: Record<string, string>; timeoutMs: number }) => $.process.run(argv, init), raiz }
353 let sessoesEmCurso = false
354 let jornadaEmCurso = false
355
356 // Arma a nota do dia quando a jornada acende e o dia ainda nao foi entregue.
357 const reavaliar = async () => {
358 try {
359 const agora = await $.clock.now()
360 const rel = avaliarRelogio({ jornada: await read($, relogioJornada), sessoes: await read($, relogioSessoes), agora })
361 if (rel.jornada) {
362 const dia = rel.jornada.dia
363 const entregue = await read($, relogioNotaEntregue)
364 const pendente = await read($, relogioNotaPendente)
365 if (entregue !== dia && pendente !== dia) await update($, relogioNotaPendente, () => dia)
366 }
367 } catch {
368 // o relogio nunca quebra nada
369 }
370 try {
371 $.ui.invalidate('ui.render')
372 } catch {
373 // sem tela, sem redesenho
374 }
375 }
376
377 const lerSessoes = async () => {
378 if (sessoesEmCurso) return
379 sessoesEmCurso = true
380 try {
381 // Depois de /clear o processo segue com outro id: le-o de novo uma vez.
382 if (armadoPor === null) armadoPor = await $.session.id()
383 const bruto = await rodarJson(io, ['node', `${raiz}/scripts/relogio-sessoes.cjs`, '--cwd', cwd, '--sessao', armadoPor], 5000, { CLAUDE_PROJECT_DIR: cwd })
384 const dados = sessoesDe(bruto)
385 await update($, relogioSessoes, () => dados)
386 await reavaliar()
387 } catch {
388 // falha apaga so a figura do relogio
389 } finally {
390 sessoesEmCurso = false
391 }
392 }
393
394 const lerJornada = async () => {
395 if (jornadaEmCurso) return
396 jornadaEmCurso = true
397 try {
398 const bruto = await rodarJson(io, ['node', `${raiz}/scripts/jornada.cjs`, '--json'], 60000, {})
399 const dados = jornadaDe(bruto)
400 await update($, relogioJornada, () => dados)
401 await reavaliar()
402 } catch {
403 // falha apaga so a figura do relogio
404 } finally {
405 jornadaEmCurso = false
406 }
407 }
408
409 // A contagem do cache e o ritmo andam sozinhos: o tique redesenha a barra a cada segundo
410 // enquanto algo conta (cache quente, ferramenta no ultimo minuto, subagente) e refaz a
411 // medicao que um compact deixou pendente. Nunca roda processo.
412 const tique = async () => {
413 if (!ativo && !medirPendente) return
414 try {
415 if (medirPendente) {
416 medirPendente = false
417 await medir({
418 usage: () => $.session.usage({ breakdown: 'summary' }),
419 gravar: f => update($, painelStats, f),
420 log: m => $.ui.log(m, { to: 'debug' }),
421 })
422 }
423 const s = inteiro(await read($, painelStats))
424 const agora = await $.clock.now()
425 const contando =
426 ritmoPorMinuto(s.carimbos, agora) > 0 ||
427 s.agentes.some(a => emAndamento(a, agora)) ||
428 cacheDe({ modelo: s.modelo, ctxTokens: s.ctxTokens, ultimaRequisicaoMs: s.ultimaRequisicaoMs, agora, ttlMs: s.ttlMs }).quente
429 if (contando) $.ui.invalidate('ui.render')
430 else {
431 ativo = false
432 if (tiqueTimer !== null) tiqueTimer.cancel()
433 tiqueTimer = null
434 }
435 } catch {
436 // o tique nunca quebra nada
437 }
438 }
439
440 timers.push($.clock.after(1000, lerSessoes))
441 timers.push($.clock.after(2000, lerJornada))
442 timers.push($.clock.every(60000, lerSessoes))
443 timers.push($.clock.every(300000, lerJornada))
444 armarTique = () => {
445 if (tiqueTimer === null) tiqueTimer = $.clock.every(1000, tique)
446 }
447 } catch {
448 // o relogio nunca quebra a abertura da sessao
449 }
450 return next(e)
451 })
452
453 // O matcher (qualquer motivo) evita colidir com o session.end sem matcher da abertura.
454 on('session.end', { reason: /.*/ }, async (_$, e, next) => {
455 if (armadoPor !== null && e.sessionId === armadoPor) {
456 // /clear nao tem session.start depois: o relogio segue e o id novo e lido no proximo tick.
457 if (e.reason === 'clear') armadoPor = null
458 else cancelarRelogio()
459 }
460 return next(e)
461 })
462
463 on('command.run', { command: 'painel' }, async ($, e) => {
464 const palavra = `${e.args ?? ''}`.trim().toLowerCase()
465 if (palavra === 'esconder' || palavra === 'mostrar') {
466 await update($, painelOculto, () => palavra === 'esconder')
467 return { text: palavra === 'esconder' ? 'Barra escondida.' : 'Barra de volta.' }
468 }
469 if (palavra === 'cache 5m' || palavra === 'cache 1h') {
470 await update($, painelStats, s => ({ ...inteiro(s), ttlMs: palavra === 'cache 5m' ? 5 * MIN : HORA }))
471 return { text: `Vida do cache em ${palavra.slice(6)}.` }
472 }
473 if (palavra === 'checar ligar' || palavra === 'checar desligar') {
474 await update($, painelDeixado, d => ({ ...deixadoInteiro(d), checar: palavra === 'checar ligar' }))
475 return { text: palavra === 'checar ligar' ? 'Checagem do segundo modelo ligada.' : 'Checagem do segundo modelo desligada.' }
476 }
477 if (palavra === 'erros' || /^erros \d+$/.test(palavra)) {
478 const horas = palavra === 'erros' ? '24' : palavra.slice(6)
479 try {
480 const cwd = await $.session.cwd()
481 const r = await $.process.run(['node', `${$.plugin.root}/scripts/erros.cjs`, 'listar', '--horas', horas], {
482 cwd: $.plugin.root,
483 env: { CLAUDE_PROJECT_DIR: cwd },
484 timeoutMs: 10000,
485 })
486 return { text: r.exitCode === 0 ? r.stdout.trimEnd() : `Log de erros indisponível (exit ${r.exitCode}).` }
487 } catch {
488 return { text: 'Log de erros indisponível.' }
489 }
490 }
491 if (palavra === 'limpar') {
492 await update($, painelDeixado, d => ({ ...deixadoInteiro(d), itens: [] }))
493 return { text: 'Deixado para depois zerado.' }
494 }
495 if (palavra !== '') return { text: AJUDA }
496 // Sem argumento: abre o pane, mede o contexto e atualiza os fluxos. Cada peca que falha
497 // some sozinha; o comando sempre devolve o texto.
498 let aberto = false
499 try {
500 aberto = (await $.ui.open({ id: PANE, title: 'Esta sessão' })).isPlaced === true
501 } catch {
502 // sem pane, o texto do comando basta
503 }
504 try {
505 await medir({
506 usage: () => $.session.usage({ breakdown: 'summary' }),
507 gravar: f => update($, painelStats, f),
508 log: m => $.ui.log(m, { to: 'debug' }),
509 })
510 const dados = await buscar({
511 rodar: (argv, init) => $.process.run(argv, init),
512 cwd: () => $.session.cwd(),
513 raiz: $.plugin.root,
514 })
515 await update($, faixaDados, () => dados)
516 } catch {
517 // a medicao nunca quebra o comando
518 }
519 if (aberto) return { text: 'Painel aberto.' }
520 const s = inteiro(await read($, painelStats))
521 return { text: `Painel indisponível aqui. Contexto ${s.ctxPct === null ? '--' : Math.round(s.ctxPct) + '%'}, custo ${dinheiro(s.custoUsd)}.` }
522 })
523
524 on('agent.spawn', async ($, e, next) => {
525 const criado = await next(e)
526 acordar()
527 try {
528 if (criado.agentId !== undefined) {
529 const id = criado.agentId
530 const at = await $.clock.now()
531 await update($, painelStats, raw => {
532 const s = inteiro(raw)
533 return {
534 ...s,
535 agentes: comAgente(s.agentes, id, at, a => ({
536 ...a,
537 rotulo: rotuloSubagente(e.description, 40),
538 tipo: String(e.subagentType),
539 modelo: criado.model,
540 criadoAqui: true,
541 fimEm: null,
542 })),
543 }
544 })
545 }
546 } catch {
547 // o contador nunca quebra o spawn
548 }
549 try {
550 await update($, painelMapa, raw => registrar(raw, { agente: true, description: e.description }).mapa as RainforestMindPainelMapa)
551 } catch {
552 // o mapa nunca quebra o spawn
553 }
554 return criado
555 })
556
557 // Nota da regra 8: uma vez por dia, so em prompt digitado no composer (nao em loop/schedule/system).
558 // Vai em `context`: o modelo le, o usuario nao ve, e o texto do prompt segue intacto. O evento e
559 // o `prompt.submit` porque o gancho classico de envio (UserPromptSubmit) nao roda, em mod, no
560 // composer do REPL.
561 on('prompt.submit', async ($, e, next) => {
562 // O pedido da pessoa fica guardado para o checker do fim do turno (so o que ela mesma mandou).
563 try {
564 const tipo: string | undefined = e.origin?.kind
565 if (tipo === undefined || tipo === 'composer' || tipo === 'bridge' || tipo === 'sdk') {
566 const pedido = String(e.text ?? '').slice(0, 4000)
567 await update($, painelDeixado, d => ({ ...deixadoInteiro(d), pedido }))
568 }
569 } catch {
570 // o pedido guardado nunca quebra o envio
571 }
572 let nota: string | null = null
573 try {
574 if (e.origin === undefined || e.origin.kind === 'composer') {
575 const pendente = await read($, relogioNotaPendente)
576 if (pendente) {
577 const agora = await $.clock.now()
578 const rel = avaliarRelogio({ jornada: await read($, relogioJornada), sessoes: await read($, relogioSessoes), agora })
579 const dia = rel.jornada ? rel.jornada.dia : null
580 const texto = notaJornada(rel) as string | null
581 if (dia && texto && pendente === dia) {
582 await update($, relogioNotaEntregue, () => dia)
583 await update($, relogioNotaPendente, () => null)
584 nota = texto
585 }
586 }
587 }
588 } catch {
589 // a nota nunca quebra o envio
590 }
591 if (nota === null) return next(e)
592 return next({ ...e, context: [...(e.context ?? []), nota] })
593 })
594
595 // Cada requisicao do laco. A vida do cache corre do comeco da ultima requisicao do laco
596 // principal; uma requisicao de subagente quer dizer que ele esta trabalhando.
597 on('turn.step', async function* ($, e, next) {
598 acordar()
599 try {
600 const at = await $.clock.now()
601 const agentId = e.agentId
602 await update($, painelStats, raw => {
603 const s = inteiro(raw)
604 return agentId === undefined
605 ? { ...s, ultimaRequisicaoMs: at }
606 : { ...s, agentes: comAgente(s.agentes, agentId, at, a => ({ ...a, fimEm: null })) }
607 })
608 } catch {
609 // o contador nunca quebra o passo
610 }
611 return yield* next(e)
612 })
613
614 on('tool.call', async ($, e, next) => {
615 const ran = await next(e)
616 acordar()
617 try {
618 const at = await $.clock.now()
619 const agentId = e.agentId
620 const falhou = ran.deny !== undefined || ran.isError === true
621 await update($, painelStats, raw => {
622 const s = inteiro(raw)
623 return {
624 ...s,
625 ferramentas: s.ferramentas + 1,
626 falhas: s.falhas + (falhou ? 1 : 0),
627 carimbos: [...s.carimbos, at].filter(t => at - t <= MIN),
628 agentes: agentId === undefined ? s.agentes : comAgente(s.agentes, agentId, at, a => ({ ...a, fimEm: null, ferramentas: a.ferramentas + 1 })),
629 }
630 })
631 } catch {
632 // o contador nunca quebra a ferramenta
633 }
634 // Cada falha vira uma linha em <raiz>/erros.jsonl na hora (scripts/erros.cjs), para triar
635 // depois com /painel erros. Na hora e nao no SessionEnd: janela fechada no X nao dispara
636 // SessionEnd. So roda em falha (custa um node, ~100 ms) e nunca quebra a ferramenta. O corte
637 // fino e o mascaramento de segredo sao do script: cortar aqui deixaria meio token sem mascara.
638 if (ran.deny !== undefined || ran.isError === true) {
639 const entrada = e as unknown as Record<string, unknown>
640 const comando = entrada.command ?? entrada.file_path ?? entrada.notebook_path ?? entrada.pattern ?? entrada.url ?? entrada.description ?? ''
641 try {
642 const cwd = await $.session.cwd()
643 const falha = {
644 sessao: await $.session.id(),
645 cwd,
646 ferramenta: String(e.tool),
647 tipo: ran.deny !== undefined ? 'bloqueio' : 'erro',
648 comando: semControle(String(comando)).slice(0, 4000),
649 mensagem: semControle(String(ran.deny ?? ran.text ?? '')).slice(0, 4000),
650 }
651 await $.process.run(['node', `${$.plugin.root}/scripts/erros.cjs`, 'gravar'], {
652 cwd: $.plugin.root,
653 env: { CLAUDE_PROJECT_DIR: cwd },
654 stdin: JSON.stringify(falha),
655 timeoutMs: 5000,
656 })
657 } catch {
658 // o log de erros nunca quebra a ferramenta
659 }
660 }
661 try {
662 const falhou = ran.deny !== undefined || ran.isError === true
663 if (e.agentId === undefined) {
664 await update($, painelDeixado, d => {
665 const x = deixadoInteiro(d)
666 return { ...x, ferramentas: [...x.ferramentas, { tool: String(e.tool), deny: ran.deny !== undefined, isError: ran.isError === true }] }
667 })
668 }
669 if (!falhou && ESCRITORAS.has(String(e.tool))) {
670 const entrada = e as unknown as Record<string, unknown>
671 const escrito = String(entrada.new_string ?? entrada.content ?? entrada.new_source ?? '')
672 const linha = (marcadoresEmArquivo(escrito) as string[])[0]
673 if (linha !== undefined) {
674 // O nome do arquivo e a linha vem do modelo: nenhum controle chega ao Text nem ao toast.
675 const arquivo = semControle(String(entrada.file_path ?? entrada.notebook_path ?? 'arquivo')).split(/[\\/]/).pop() ?? 'arquivo'
676 await anotar(
677 { gravar: f => update($, painelDeixado, f), avisar: t => $.ui.toast(t) },
678 [`Escreveu "${semControle(linha).slice(0, 90)}" em ${arquivo}`],
679 'em arquivo',
680 )
681 }
682 }
683 } catch {
684 // o deixado nunca quebra a ferramenta
685 }
686 try {
687 if (ran.deny === undefined && ran.isError !== true) {
688 const escrita = escritaDe(e) as string | null
689 if (escrita === null) {
690 await update($, painelMapa, raw => registrar(raw, e).mapa as RainforestMindPainelMapa)
691 } else {
692 // Escrita: o script do desvio roda em segundo plano (3 a 5 s medidos) e o resultado
693 // da ferramenta nao espera por ele. Um spawn por vez: as escritas que chegam durante
694 // ele esperam na fila, e cada caminho roda uma vez so (veredito ou espera ja valem
695 // por ele). O arquivo entra no mapa quando a resposta chega, para o `novo` do desvio
696 // valer uma vez por arquivo (D15). Nada daqui vai ao modelo.
697 if (desvioConsultado.has(escrita) || desvioPendente.has(escrita)) {
698 // ja tem veredito, ou ja espera a vez
699 } else if (filaDesvio.length >= FILA_DESVIO_MAX) {
700 // Fila cheia: lista sem veredito. O caminho nao fica marcado, entao a proxima escrita
701 // nele tenta de novo; arquivo que nao for reescrito nao tem o desvio checado.
702 const rel = relConhecido.get(escrita)
703 await update($, painelMapa, raw => registrar(raw, rel === undefined ? e : { tool: e.tool, file_path: rel, notebook_path: rel }).mapa as RainforestMindPainelMapa)
704 } else {
705 const raiz = $.plugin.root
706 const log = (err: unknown) => {
707 try {
708 $.ui.log(`painel: desvio: ${String(err)}`, { to: 'debug' })
709 } catch {
710 // sem log, sem problema
711 }
712 }
713 // Devolve true quando o script respondeu um veredito; falha aberta, nunca lanca.
714 const rodar = async (): Promise<boolean> => {
715 let desvio: { veredito?: string; rel?: string | null } | null = null
716 try {
717 const cwd = await $.session.cwd()
718 const r = await $.process.run(
719 ['node', `${raiz}/scripts/desvio-do-plano.cjs`, '--cwd', cwd, '--arquivo', escrita],
720 // Roda em segundo plano depois da escrita: sob carga passa de 5 s, e o teto folga.
721 { cwd: raiz, env: { CLAUDE_PROJECT_DIR: cwd }, timeoutMs: 10000 },
722 )
723 const lido = r.exitCode === 0 ? JSON.parse(r.stdout) : null
724 desvio = lido !== null && typeof lido === 'object' && typeof lido.veredito === 'string' ? lido : null
725 } catch (err) {
726 log(err)
727 }
728 try {
729 if (typeof desvio?.rel === 'string' && desvio.rel !== '') relConhecido.set(escrita, desvio.rel)
730 const caminho: string = relConhecido.get(escrita) ?? escrita
731 const escreveu = { tool: e.tool, file_path: caminho, notebook_path: caminho }
732 let avisar = false
733 await update($, painelMapa, cru => {
734 const raw = trocarCaminho(cru, escrita, caminho)
735 if (desvio === null) return registrar(raw, escreveu).mapa as RainforestMindPainelMapa
736 const marcado = registrar(raw, { desvio: true, caminho })
737 if (desvio.veredito === 'fora' && marcado.novo) {
738 avisar = true
739 }
740 return (desvio.veredito === 'fora' ? marcado.mapa : registrar(raw, escreveu).mapa) as RainforestMindPainelMapa
741 })
742 if (avisar) $.ui.toast(`Fora dos arquivos do plano: ${caminho}`)
743 } catch (err) {
744 log(err)
745 }
746 // So veredito definitivo vai ao cache: sem-fluxo, sem-plano, sem-arquivos e fora-da-raiz
747 // mudam na mesma sessao (o plano nasce depois da escrita), e a reescrita checa de novo.
748 return desvio !== null && VEREDITOS_DEFINITIVOS.has(String(desvio.veredito))
749 }
750 desvioPendente.add(escrita)
751 filaDesvio.push({ escrita, rodar })
752 void drenarDesvios()
753 }
754 }
755 }
756 } catch {
757 // o mapa nunca quebra a ferramenta
758 }
759 return ran
760 })
761
762 on('turn.complete', async ($, e, next) => {
763 const agentId = e.agentId
764 acordar()
765 const log = (m: string) => {
766 try {
767 $.ui.log(m, { to: 'debug' })
768 } catch {
769 // sem log, sem problema
770 }
771 }
772 // As ferramentas e o pedido do turno saem do estado e a lista zera ANTES de qualquer outra
773 // espera: o turno seguinte ja pode estar chamando ferramentas enquanto este fecha as medidas.
774 const turno: { d: RainforestMindPainelDeixado | null } = { d: null }
775 if (agentId === undefined) {
776 try {
777 await update($, painelDeixado, x => {
778 const d = deixadoInteiro(x)
779 turno.d = d
780 return { ...d, ferramentas: [] }
781 })
782 } catch {
783 // o deixado nunca quebra o turno
784 }
785 }
786 try {
787 const at = await $.clock.now()
788 const saida = e.usage?.output_tokens ?? 0
789 // `input_tokens` e so a parte sem cache; um prompt em cache reporta quase tudo nos dois
790 // campos de cache.
791 const novos = e.usage?.input_tokens ?? 0
792 const lidos = e.usage?.cache_read_input_tokens ?? 0
793 const escritos = e.usage?.cache_creation_input_tokens ?? 0
794 await update($, painelStats, raw => {
795 const s = inteiro(raw)
796 return {
797 ...s,
798 // So onde nenhuma requisicao do laco principal foi vista comecar.
799 ultimaRequisicaoMs: agentId === undefined && s.ultimaRequisicaoMs === 0 ? at : s.ultimaRequisicaoMs,
800 modelo: agentId === undefined ? (e.usage?.model ?? s.modelo) : s.modelo,
801 turnos: s.turnos + (agentId === undefined ? 1 : 0),
802 tokensNovos: s.tokensNovos + novos,
803 tokensCacheLido: s.tokensCacheLido + lidos,
804 tokensCacheEscrito: s.tokensCacheEscrito + escritos,
805 tokensSaida: s.tokensSaida + saida,
806 agentes: agentId === undefined
807 ? s.agentes
808 : comAgente(s.agentes, agentId, at, a => ({
809 ...a,
810 fimEm: at,
811 modelo: e.usage?.model ?? a.modelo,
812 tokensLidos: a.tokensLidos + novos + lidos + escritos,
813 tokensSaida: a.tokensSaida + saida,
814 falhou: e.reason === 'error' || e.reason === 'aborted',
815 })),
816 }
817 })
818 if (agentId === undefined) {
819 // Sem await: medir (session.usage) e buscar (scripts) levam segundos e o turno nao
820 // espera por eles. Falha aberta: cada um apaga so a propria leitura.
821 medir({
822 usage: () => $.session.usage({ breakdown: 'summary' }),
823 gravar: f => update($, painelStats, f),
824 log: m => $.ui.log(m, { to: 'debug' }),
825 }).catch((err: unknown) => log(`painel: medir: ${String(err)}`))
826 buscar({
827 rodar: (argv, init) => $.process.run(argv, init),
828 cwd: () => $.session.cwd(),
829 raiz: $.plugin.root,
830 })
831 .then(dados => update($, faixaDados, () => dados))
832 .catch((err: unknown) => log(`painel: buscar: ${String(err)}`))
833 }
834 } catch {
835 // a barra nunca quebra o turno
836 }
837 if (agentId === undefined) {
838 try {
839 const d = turno.d
840 if (d !== null && e.reason === 'answer') {
841 const relato = String(e.answer ?? '')
842 const io = { gravar: (f: (x: RainforestMindPainelDeixado) => RainforestMindPainelDeixado) => update($, painelDeixado, f), avisar: (t: string) => $.ui.toast(t) }
843 await anotar(io, deferimentos(relato) as string[], 'Claude disse')
844 const ligado = d.checar
845 const ferramentas = d.ferramentas
846 const pedido = d.pedido
847 // Duas metades: achar pendencia nova pede turno de 5+ ferramentas e relato sem decisao
848 // `Q<n>` para a pessoa (a Q espera a palavra dela); fechar item resolvido roda em todo
849 // turno com item aberto, porque a fala da pessoa no pedido tambem e evidencia.
850 const abertos = d.itens.filter(i => i.estado === 'aberto').map(i => ({ id: i.id, texto: i.texto }))
851 const acharNovos = ferramentas.length >= CHECAR_MIN_FERRAMENTAS && !perguntaDecisao(relato)
852 const deveChecar = ligado && pedido !== '' && (acharNovos || abertos.length > 0);
853 if (deveChecar) {
854 // Sem await: o turno acaba agora e o achado chega quando chegar.
855 await update($, painelDeixado, x => ({ ...deixadoInteiro(x), checando: true }))
856 const checagem = async () => {
857 try {
858 const r = await $.model.complete({
859 model: 'haiku',
860 prompt: montarPromptChecker({ pedido, relato, ferramentas, abertos }),
861 maxTokens: 300,
862 timeoutMs: 30000,
863 })
864 if (!r.isAnswered) {
865 log(`painel: checker: o modelo nao respondeu (${r.reason})`)
866 return
867 }
868 // So `aberto -> resolvido`, dentro do update: o estado pode ter mudado enquanto o
869 // modelo pensava (Faz agora, limpar).
870 const resolvidos = new Set(lerResolvidosChecker(r.text, abertos.map(i => i.id)) as number[])
871 if (resolvidos.size > 0) {
872 await update($, painelDeixado, x => {
873 const y = deixadoInteiro(x)
874 return { ...y, itens: y.itens.map(i => (i.estado === 'aberto' && resolvidos.has(i.id) ? { ...i, estado: 'resolvido' as const } : i)) }
875 })
876 }
877 if (acharNovos) await anotar(io, lerRespostaChecker(r.text) as string[], 'segundo modelo')
878 } finally {
879 try {
880 await update($, painelDeixado, (d) => ({ ...d, checando: false }));
881 } catch {
882 // sem estado, o aviso de checando some quando o pane redesenhar
883 }
884 }
885 }
886 checagem().catch((err: unknown) => log(`painel: checker: ${String(err)}`))
887 }
888 }
889 } catch {
890 // o deixado nunca quebra o turno
891 }
892 }
893 return next(e)
894 })
895
896 // A conversa compactada e outra janela: mostra a contagem da propria compactacao na hora e
897 // pede medicao nova (o engine troca a conversa depois que este hook volta, entao uma leitura
898 // agora ainda seria a velha).
899 // O matcher casa qualquer gatilho: register.ts ja tem um session.compact sem matcher.
900 on('session.compact', { trigger: /.*/ }, async ($, e, next) => {
901 const feito = await next(e)
902 try {
903 if (e.agentId !== undefined || e.trigger === 'precompute' || feito.messages === undefined) return feito
904 const depois = feito.tokensAfter
905 medirPendente = true
906 acordar()
907 await update($, painelStats, raw => {
908 const s = inteiro(raw)
909 const medida = depois !== undefined && s.ctxJanela !== null && s.ctxJanela > 0
910 return {
911 ...s,
912 medirDeNovo: true,
913 ctxTokens: depois ?? s.ctxTokens,
914 ctxPct: medida ? (depois / (s.ctxJanela ?? 1)) * 100 : s.ctxPct,
915 fatias: medida ? [] : s.fatias,
916 }
917 })
918 } catch {
919 // o contador nunca quebra a compactacao
920 }
921 return feito
922 })
923
924 // O pane do /painel: fluxos em curso, onde foi o contexto, cache, subagentes e custo. Toda
925 // leitura que falta vira uma frase no proprio painel; excecao devolve next(e).
926 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
927 try {
928 const { Box, Text, Button } = $.ui.resolve(e)
929 const s = inteiro(await read($, painelStats))
930 const dados = await read($, faixaDados)
931 const deixado = deixadoInteiro(await read($, painelDeixado))
932 const agora = await $.clock.now()
933 const interior = Math.max(16, e.props.bodyColumns - 4)
934 const visto = e.props.view?.agentId
935 const corte = (texto: string, margem: number): string => rotuloSubagente(texto, Math.max(8, interior - margem))
936
937 const linha = (chave: string, rotulo: string, valor: string, cor: string) => (
938 <Box key={chave} flexDirection="row" justifyContent="space-between">
939 <Text color={COR.ambar} dimColor>{rotulo}</Text>
940 <Text color={cor} bold>{valor}</Text>
941 </Box>
942 )
943 const painel = (chave: string, cor: string, titulo: string, filhos: unknown) => (
944 <Box key={chave} flexDirection="column" borderStyle="single" borderColor={cor} paddingX={1}>
945 <Text backgroundColor={cor} color="#000000" bold>{` ${titulo} `}</Text>
946 {filhos}
947 </Box>
948 )
949 const linhasAgentes = (limite: number) =>
950 [...s.agentes]
951 .sort((a, b) => b.iniciadoEm - a.iniciadoEm)
952 .slice(0, limite)
953 .map(a => {
954 const roda = emAndamento(a, agora)
955 const cor = roda ? COR.verde : a.falhou ? COR.vermelho : COR.ciano
956 const seta = a.id === visto ? '▶ ' : ''
957 return (
958 <Box key={`ag-${a.id}`} flexDirection="column">
959 <Box flexDirection="row" justifyContent="space-between">
960 <Text color={cor} bold>{corte(`${seta}${roda ? '●' : a.falhou ? '✗' : '✓'} ${a.rotulo}`, 7)}</Text>
961 <Text color={cor}>{mmss((roda ? agora : (a.fimEm ?? a.vistoEm)) - a.iniciadoEm)}</Text>
962 </Box>
963 <Text dimColor>{corte(` ${roda ? 'trabalhando' : a.falhou ? 'parou' : 'pronto'} · ${a.tipo} · ${a.modelo ?? 'modelo desconhecido'} · ${a.ferramentas} ferramentas`, 0)}</Text>
964 </Box>
965 )
966 })
967
968 // O transcript de um subagente esta em tela: o pane e dele, e contexto e cache sao da principal.
969 if (visto !== undefined) {
970 const a = s.agentes.find(x => x.id === visto)
971 const roda = a !== undefined && emAndamento(a, agora)
972 const cor = a === undefined ? COR.ambar : roda ? COR.verde : a.falhou ? COR.vermelho : COR.ciano
973 return (
974 <Box flexDirection="column">
975 {painel('p-agente', COR.ciano, 'Subagente em tela', [
976 <Text key="nome" color={cor} bold>{corte(a?.rotulo ?? `agente ${visto.slice(0, 6)}`, 0)}</Text>,
977 a === undefined ? <Text key="sem" dimColor>Sem atividade vista ainda</Text> : null,
978 a !== undefined ? linha('status', 'Status', roda ? 'trabalhando' : a.falhou ? 'parou' : 'pronto', cor) : null,
979 a !== undefined ? linha('tempo', 'Tempo', mmss((roda ? agora : (a.fimEm ?? a.vistoEm)) - a.iniciadoEm), cor) : null,
980 a !== undefined ? linha('tipo', 'Tipo', a.tipo, COR.ciano) : null,
981 a !== undefined ? linha('modelo', 'Modelo', a.modelo ?? 'desconhecido', COR.ciano) : null,
982 a !== undefined ? linha('ferr', 'Ferramentas', `${a.ferramentas}`, COR.ciano) : null,
983 a !== undefined ? linha('lidos', 'Tokens lidos', compacto(a.tokensLidos), COR.ciano) : null,
984 a !== undefined ? linha('escritos', 'Tokens escritos', compacto(a.tokensSaida), COR.verde) : null,
985 <Text key="nota" dimColor>Os tokens sobem cada vez que ele termina uma rodada</Text>,
986 ])}
987 {painel('p-agentes', COR.ambar, 'Todos os subagentes', linhasAgentes(AGENTES_NO_PANE))}
988 <Text dimColor>Contexto e cache são da sessão principal. Volte a ela para vê-los.</Text>
989 </Box>
990 )
991 }
992
993 // Fluxos em curso (D4): o que esta aberto neste repositorio, lido de faixa-dados.cjs.
994 const fluxos = dados?.fluxos ?? []
995 // Leitura que falhou (exit, timeout, JSON) nao se mostra como "nenhum": vazio nao e ok.
996 const painelFluxos = painel('p-fluxos', COR.ciano, 'Fluxos em curso', dados === null
997 ? <Text color={COR.ambar}>Leitura dos fluxos indisponível agora; tenta de novo no fim do próximo turno</Text>
998 : fluxos.length === 0
999 ? <Text dimColor>Nenhum fluxo em curso</Text>
1000 : fluxos.map(f => {
1001 const partes = [f.etapa]
1002 if (f.tarefas !== null && f.tarefas_ok !== null) partes.push(`${f.tarefas_ok}/${f.tarefas}`)
1003 if (f.em_voo.length > 0) partes.push(`${f.em_voo.length} em voo`)
1004 const direita = rotuloSubagente(partes.join(' · '), Math.max(24, Math.floor(interior / 2)))
1005 return (
1006 <Box key={`fl-${f.slug}`} flexDirection="row" justifyContent="space-between">
1007 <Text color={COR.ciano} bold>{rotuloSubagente(slugSemData(f.slug), Math.max(8, interior - largura(direita) - 2))}</Text>
1008 <Text>{direita}</Text>
1009 </Box>
1010 )
1011 }))
1012
1013 // Onde foi o contexto: maiores primeiro, depois o livre e a reserva; as ferramentas
1014 // carregadas sob demanda ficam fora da janela e so sao listadas.
1015 const ocupadas = s.fatias.filter(f => f.tipo === 'used').sort((a, b) => b.tokens - a.tokens)
1016 const sobras = s.fatias.filter(f => f.tipo === 'free' || f.tipo === 'buffer')
1017 const sobDemanda = s.fatias.filter(f => f.tipo === 'deferred').reduce((soma, f) => soma + f.tokens, 0)
1018 const desenhadas = [...ocupadas, ...sobras]
1019 const celulas = fatias(desenhadas, interior);
1020 const janela = desenhadas.reduce((soma, f) => soma + f.tokens, 0)
1021 const tinta = (f: Fatia): string => (f.tipo === 'used' ? (CORES_FATIA[ocupadas.indexOf(f) % CORES_FATIA.length] ?? COR.ciano) : COR.cinza)
1022 const celula = (f: Fatia): string => (f.tipo === 'used' ? '█' : f.tipo === 'buffer' ? '▒' : '░')
1023 const restoUsado = ocupadas.slice(FATIAS_NA_LEGENDA).reduce((soma, f) => soma + f.tokens, 0)
1024 const legenda = [...ocupadas.slice(0, FATIAS_NA_LEGENDA), ...sobras]
1025 const corCtx = (s.ctxPct ?? 0) > 80 ? COR.vermelho : COR.verde
1026 const painelContexto = painel('p-contexto', COR.ambar, 'Onde foi o contexto', [
1027 linha('usado', 'Usado', s.ctxPct === null ? '--' : `${Math.round(s.ctxPct)}% de ${compacto(s.ctxJanela ?? 0)}`, corCtx),
1028 desenhadas.length === 0 ? <Text key="indisp" color={COR.ambar}>Medição indisponível</Text> : null,
1029 desenhadas.length > 0 ? (
1030 <Box key="barra" flexDirection="row">
1031 {desenhadas.map((f, i) => ((celulas[i] ?? 0) > 0 ? <Text key={`c-${i}`} color={ocupadas.indexOf(f) >= FATIAS_NA_LEGENDA ? COR.cinza : tinta(f)}>{celula(f).repeat(celulas[i] ?? 0)}</Text> : null))}
1032 </Box>
1033 ) : null,
1034 ...legenda.map((f, i) => (
1035 <Box key={`lg-${i}`} flexDirection="row" justifyContent="space-between">
1036 <Text color={tinta(f)}>{`${celula(f) === '█' ? '■' : celula(f)} ${rotuloSubagente(f.nome, Math.max(8, interior - 14))}`}</Text>
1037 <Text color={tinta(f)} bold>{`${compacto(f.tokens)} ${`${Math.round((f.tokens / Math.max(1, janela)) * 100)}%`.padStart(4)}`}</Text>
1038 </Box>
1039 )),
1040 restoUsado > 0 ? <Text key="resto" dimColor>{`■ ${ocupadas.length - FATIAS_NA_LEGENDA} itens menores, ${compacto(restoUsado)}`}</Text> : null,
1041 sobDemanda > 0 ? <Text key="demanda" dimColor>{`+ ${compacto(sobDemanda)} em ferramentas sob demanda`}</Text> : null,
1042 ])
1043
1044 // Cache de prompt: quente enquanto a ultima requisicao ainda cabe na vida do cache.
1045 const cache = cacheDe({ modelo: s.modelo, ctxTokens: s.ctxTokens, ultimaRequisicaoMs: s.ultimaRequisicaoMs, agora, ttlMs: s.ttlMs })
1046 const painelCache = painel('p-cache', COR.ambar, 'Cache de prompt', [
1047 s.ultimaRequisicaoMs === 0 ? <Text key="sem" dimColor>Medido depois do próximo turno</Text> : null,
1048 s.ultimaRequisicaoMs !== 0 ? linha('agora', 'Agora', cache.quente ? `quente, faltam ${restante(cache.restanteMs)}` : 'frio', cache.quente ? COR.vermelho : COR.ciano) : null,
1049 s.ultimaRequisicaoMs !== 0 ? linha('quente', 'Próxima mensagem com o cache quente', dinheiro(cache.custoQuente), COR.verde) : null,
1050 s.ultimaRequisicaoMs !== 0 ? linha('frio', 'Próxima mensagem com o cache frio', dinheiro(cache.custoFrio), cache.quente ? COR.ambar : COR.vermelho) : null,
1051 <Text key="nota" dimColor>{`Estimativa: reenvio da conversa a preço de lista, cache de ${s.ttlMs > 5 * MIN ? '1h' : '5m'}`}</Text>,
1052 ])
1053
1054 // Mapa da sessao (D13): so o que foi escrito por Edit, Write e NotebookEdit, skills, servicos
1055 // MCP e subagentes. Ler e Bash nao entram; fora de fluxo nada fica vermelho.
1056 const mapa = { ...mapaVazio(), ...(await read($, painelMapa)) } as RainforestMindPainelMapa
1057 const fim = (texto: string, max: number): string => (largura(texto) > max ? `…${texto.slice(-(Math.max(4, max) - 1))}` : texto)
1058 const secao = (chave: string, titulo: string, itens: unknown[], vazio: string) => [
1059 <Text key={`mt-${chave}`} color={COR.ambar} dimColor>{titulo}</Text>,
1060 ...(itens.length === 0 ? [<Text key={`mv-${chave}`} dimColor>{` ${vazio}`}</Text>] : itens),
1061 ]
1062 const painelMapa_ = painel('p-mapa', COR.ciano, 'Mapa da sessão', [
1063 ...secao('arq', 'Arquivos escritos', mapa.arquivos.map((a, i) => (
1064 <Box key={`ma-${i}`} flexDirection="column">
1065 <Text color={a.desvio ? COR.vermelho : COR.verde} bold={a.desvio}>{fim(`${a.desvio ? '✗' : '✓'} ${a.caminho}`, interior - 2)}</Text>
1066 {a.desvio ? <Text color={COR.vermelho}>{' fora dos arquivos do plano'}</Text> : null}
1067 </Box>
1068 )), 'nenhum'),
1069 ...secao('sk', 'Skills', mapa.skills.map((x, i) => <Text key={`ms-${i}`} color={COR.ciano}>{fim(` ${x}`, interior)}</Text>), 'nenhuma'),
1070 ...secao('sv', 'Serviços MCP', mapa.servicos.map((x, i) => <Text key={`mc-${i}`} color={COR.ciano}>{fim(` ${x}`, interior)}</Text>), 'nenhum'),
1071 ...secao('sa', 'Subagentes chamados', mapa.subagentes.map((x, i) => <Text key={`mg-${i}`} color={COR.ciano}>{fim(` ${x}`, interior)}</Text>), 'nenhum'),
1072 <Text key="mnota" dimColor>Escrita feita por Bash não é detectada; só Edit, Write e NotebookEdit entram aqui.</Text>,
1073 ])
1074
1075 // Deixado para depois (D7): o que Claude adiou, escreveu como pendencia ou o segundo modelo
1076 // achou faltando. "Faz agora" so preenche o prompt; quem envia e a pessoa.
1077 const abertos = deixado.itens.filter(i => i.estado === 'aberto')
1078 const fazAgora = async (id: number, texto: string): Promise<void> => {
1079 try {
1080 const preenchido = await $.prompt.fill({ text: rascunhoFazAgora(texto) as string, mode: 'append' })
1081 // Prompt que recusou o texto deixa o item aberto: nada chegou a pessoa.
1082 if (!preenchido?.isFilled) return
1083 await update($, painelDeixado, d => ({ ...deixadoInteiro(d), itens: deixadoInteiro(d).itens.map(i => (i.id === id ? { ...i, estado: 'enviado' as const } : i)) }))
1084 } catch (err) {
1085 try {
1086 $.ui.log(`painel: deixado: ${String(err)}`, { to: 'debug' })
1087 } catch {
1088 // sem log, sem problema
1089 }
1090 }
1091 }
1092 const limpar = async (): Promise<void> => {
1093 try {
1094 await update($, painelDeixado, d => ({ ...deixadoInteiro(d), itens: [] }))
1095 } catch {
1096 // limpar nunca quebra o pane
1097 }
1098 }
1099 const painelDeixadoTela = painel('p-deixado', COR.vermelho, 'Deixado para depois', [
1100 deixado.checando ? <Text key="checando" color={COR.ambar}>Checando com o segundo modelo…</Text> : null,
1101 abertos.length === 0 && !deixado.checando ? <Text key="nada" dimColor>Nada marcado. O trabalho que Claude deixar para depois aparece aqui.</Text> : null,
1102 ...abertos.map((item, i) => (
1103 <Box key={`dx-${item.id}`} flexDirection="column" marginTop={i === 0 ? 0 : 1}>
1104 <Text color={COR.vermelho} bold>{`D${item.id} · ${item.origem}`}</Text>
1105 <Text wrap="wrap">{item.texto}</Text>
1106 <Button key={`faz-${item.id}`} label="Faz agora (só preenche o prompt)" onPress={() => fazAgora(item.id, item.texto)} />
1107 </Box>
1108 )),
1109 abertos.length > 0 ? <Button key="deixado-limpar" label="Limpar tudo" onPress={limpar} /> : null,
1110 <Text key="dnota" dimColor>{`Checagem do segundo modelo: ${deixado.checar ? 'ligada' : 'desligada'} (/painel checar ligar|desligar)`}</Text>,
1111 ])
1112
1113 const lidos = s.tokensNovos + s.tokensCacheLido + s.tokensCacheEscrito
1114 const doCache = lidos === 0 ? 0 : Math.round((s.tokensCacheLido / lidos) * 100)
1115 return (
1116 <Box flexDirection="column">
1117 {painelFluxos}
1118 {painelDeixadoTela}
1119 {painelMapa_}
1120 {painelContexto}
1121 {painelCache}
1122 {painel('p-agentes', COR.ambar, 'Subagentes', s.agentes.length === 0 ? <Text dimColor>Nenhum iniciado ainda</Text> : linhasAgentes(AGENTES_NO_PANE))}
1123 {painel('p-custo', COR.ambar, 'Custo e tokens', [
1124 linha('custo', 'Custo da sessão', dinheiro(s.custoUsd), COR.ambar),
1125 linha('lidos', 'Tokens lidos', compacto(lidos), COR.ciano),
1126 linha('servido', ' servidos do cache', `${doCache}%`, COR.ciano),
1127 linha('escritos', 'Tokens escritos', compacto(s.tokensSaida), COR.verde),
1128 linha('turnos', 'Turnos', `${s.turnos}`, COR.ciano),
1129 <Text key="nota" dimColor>Tokens e turnos contam desde que o mod carregou</Text>,
1130 ])}
1131 </Box>
1132 )
1133 } catch {
1134 return next(e)
1135 }
1136 })
1137
1138 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1139 try {
1140 if (e.props.hasSurvey || (await read($, painelOculto))) return next(e)
1141 const { Box, Text } = $.ui.resolve(e)
1142 const s = inteiro(await read($, painelStats))
1143 const agora = await $.clock.now()
1144 const cols = e.props.bodyColumns
1145 const visto = e.props.view?.agentId
1146
1147 // O transcript de um subagente esta em tela: a barra e dele, e o contexto e o da sessao principal.
1148 if (visto !== undefined) {
1149 const a = s.agentes.find(x => x.id === visto)
1150 const roda = a !== undefined && emAndamento(a, agora)
1151 const cor = a === undefined ? COR.ambar : roda ? COR.verde : a.falhou ? COR.vermelho : COR.ciano
1152 const partes: { texto: string; cor: string }[] = [
1153 { texto: ' Subagente ', cor: COR.ciano },
1154 { texto: rotuloSubagente(a?.rotulo ?? `agente ${visto.slice(0, 6)}`, 24), cor },
1155 ]
1156 if (a === undefined) {
1157 partes.push({ texto: 'Sem atividade vista ainda', cor: COR.ambar })
1158 } else {
1159 partes.push(
1160 { texto: 'Status ' + (roda ? 'trabalhando' : a.falhou ? 'parou' : 'pronto'), cor },
1161 { texto: 'Tempo ' + mmss((roda ? agora : (a.fimEm ?? a.vistoEm)) - a.iniciadoEm), cor },
1162 { texto: 'Ferram. ' + a.ferramentas, cor },
1163 { texto: 'Tokens ' + compacto(a.tokensLidos + a.tokensSaida), cor },
1164 { texto: 'Modelo ' + rotuloSubagente(a.modelo ?? 'desconhecido', 24), cor },
1165 )
1166 }
1167 partes.push({ texto: 'Sessão principal: contexto ' + (s.ctxPct === null ? '--' : Math.round(s.ctxPct) + '%'), cor: COR.ambar })
1168 const ficam: { texto: string; cor: string }[] = []
1169 let usado = 0
1170 for (const p of partes) {
1171 const custo = largura(p.texto) + (ficam.length > 0 ? 2 : 0)
1172 if (ficam.length > 0 && usado + custo > cols) break
1173 ficam.push(ficam.length === 0 ? { ...p, texto: cortar(p.texto, cols) } : p)
1174 usado += custo
1175 }
1176 return (
1177 <Box flexDirection="row" height={1} overflow="hidden">
1178 {ficam.map((p, i) => (
1179 <Box key={`a${i}`} marginLeft={i === 0 ? 0 : 2}>
1180 <Text color={p.cor} wrap="truncate-end">{p.texto}</Text>
1181 </Box>
1182 ))}
1183 </Box>
1184 )
1185 }
1186
1187 let relogio: string | null = null
1188 try {
1189 relogio = linhaDoRelogio(await read($, relogioJornada), await read($, relogioSessoes), agora)
1190 } catch {
1191 // sem relogio, a barra segue
1192 }
1193 const figuras = figurasDaBarra(
1194 {
1195 trabalhando: e.props.isWorking,
1196 tokens: s.tokensNovos + s.tokensCacheLido + s.tokensCacheEscrito + s.tokensSaida,
1197 custoUsd: s.custoUsd,
1198 ctxPct: s.ctxPct ?? undefined,
1199 ctxTokens: s.ctxTokens ?? undefined,
1200 modelo: s.modelo,hooks/register.ts 42 lines1// Mod da abertura (fluxo 2026-10-02-mod-regras-inteiras): as regras e a memoria entram
2// como secao do system prompt, sem o teto de entrega do hook SessionStart. Toda a logica
3// mora em ./abertura-mod-puro.mjs (testavel sem engine); aqui so se liga os hooks.
4// Um modulo de mod com `import()` dinamico nao carrega: o import abaixo e estatico.
5// O engine tambem recusa `$` passado como argumento: a logica recebe so tres funcoes
6// tiradas dele, escritas no ponto de chamada.
7import type { Register } from 'claude-code'
8import { criarAbertura } from './abertura-mod-puro.mjs'
9
10export const register: Register = on => {
11 const abertura = criarAbertura()
12
13 on('session.end', ($, e, next) => {
14 abertura.aoEncerrar(e.reason)
15 return next(e)
16 })
17
18 on('prompt.compose', async ($, e, next) => {
19 const r = await next(e)
20 const texto = await abertura.obter({
21 rodar: (argv, init) => $.process.run(argv, init),
22 cwd: () => $.session.cwd(),
23 raiz: $.plugin.root,
24 })
25 return abertura.comSecao(r, texto)
26 })
27
28 on('classic.SessionStart', async ($, e, next) => {
29 const r = await next(e)
30 const texto = await abertura.obter({
31 rodar: (argv, init) => $.process.run(argv, init),
32 cwd: () => $.session.cwd(),
33 raiz: $.plugin.root,
34 })
35 return abertura.semAbertura(r, texto)
36 })
37
38 // Conta com sec-default: o compose e o SessionStart do plugin sao barrados por politica da
39 // organizacao. O mod nao tenta entregar a abertura por outro canal (nenhum handler de
40 // engine.create, session.start, prompt.submit ou session.compact); ver abertura-mod-puro.mjs.
41}
42hooks/deixado-puro.mjs 141 lines1// Logica pura do "deixado para depois" do mod: frases de adiamento em portugues e
2// ingles, marcadores em arquivo escrito e o prompt do checker. ES module sem Node e sem
3// o objeto do engine: tudo entra por argumento.
4// Contrato: docs/rainforest/planos/2026-10-07-desk-no-mod.md, tarefa 2.
5// Frases e marcadores partem das regex do terminal-desk 0.2.1 (licenca MIT, titular
6// ClariSortAi); texto da licenca e origem em NOTICE, na raiz do plugin.
7
8// Um turno com menos ferramentas que isso trabalhou pouco para valer um checker.
9export const CHECAR_MIN_FERRAMENTAS = 5;
10
11import { semControle } from './faixa-puro.mjs';
12
13// Como uma resposta diz que adiou trabalho. Roda sobre texto normalizado por
14// `semCitacao` (sem acento, minusculo, sem o que esta entre aspas ou crases).
15const DITO = /\b(?:por enquanto|fic(?:a|am|ou|aram|ara|arao) (?:para|pra) depois|ficou faltando|ficaram faltando|deixei para depois|proxima (?:fase|etapa|rodada|passada)|nao (?:rodei|testei|verifiquei|implementei|executei|conferi|consegui)|sem (?:rodar|testar|verificar)|fora do escopo|em outra (?:rodada|passada|etapa)|placeholder|for now|follow[- ]up|out of scope|not yet|i (?:didn't|did not|haven't|have not|skipped|left)\b|(?:do|handle|add|fix|revisit|address|tackle) (?:that|this|it|them|those) later|in a later (?:pass|step|turn|change|pr)|still needs?|remains? to be|stubbed|untested|not (?:verified|tested|implemented|wired up))/i;
16
17// "Nada fica para depois", "nao ficou faltando": a negacao desfaz o adiamento, entao a
18// expressao negada sai do texto antes de DITO rodar.
19const NEGADO = /\b(?:nada|nenhum[a-z]*|nem|nao)\s+(?:[a-z]+\s+){0,2}?fic(?:a|am|ou|aram|ara|arao)\s+(?:(?:para|pra) depois|faltando)/g;
20
21// Como trabalho adiado aparece quando ja foi escrito num arquivo. `TODO` em maiusculas
22// e palavra portuguesa neste repo ("corpo de TODO heredoc"), por isso o marcador so conta
23// seguido de `:` ou `(`.
24const MARCA = /\b(?:TODO|FIXME|XXX)\s*[:(]|not implemented|NotImplemented|\.skip\(|\bx(?:it|describe)\(|@pytest\.mark\.skip/;
25
26// O que Claude cita entre aspas ou mostra como codigo nao e Claude adiando trabalho.
27const semCitacao = (parte) =>
28 parte
29 .replace(/"[^"]*"|“[^”]*”|`[^`]*`/g, ' ')
30 .replace(/’/g, "'")
31 .normalize('NFD')
32 .replace(/[̀-ͯ]/g, '')
33 .toLowerCase();
34
35// Decisao que sobe para a pessoa (regra 16: `Q1`, `**Q2**`, `Q3:`) espera a palavra dela,
36// nao e trabalho que Claude adiou.
37const LINHA_Q = /^[\s>*#-]*Q\d+\b/;
38const semCodigo = (texto) => String(texto ?? '').replace(/```[\s\S]*?```/g, ' ');
39
40// O relato pergunta decisao a pessoa: uma linha abre com `Q<n>`, fora de bloco de codigo.
41export const perguntaDecisao = (relato) => semCodigo(relato).split(/\n+/).some(linha => LINHA_Q.test(linha));
42
43// As frases de uma resposta que adiam trabalho, blocos de codigo e linhas `Q<n>` fora; ate 3,
44// 200 cars.
45export const deferimentos = (resposta) =>
46 semCodigo(resposta)
47 .split(/\n+/)
48 .filter(linha => !LINHA_Q.test(linha))
49 .join('\n')
50 .split(/(?<=[.!?])\s+|\n+/)
51 // Texto do modelo: ESC, BEL e override bidi nunca chegam ao Text, ao toast nem ao prompt.
52 .map(parte => semControle(parte.replace(/^[\s>*#-]+/, '').replace(/\*\*/g, '')).trim())
53 .filter(parte => parte.length > 12 && DITO.test(semCitacao(parte).replace(NEGADO, ' ')))
54 .map(parte => parte.slice(0, 200))
55 .slice(0, 3);
56
57// As linhas de um arquivo escrito que carregam marcador de trabalho adiado.
58export const marcadoresEmArquivo = (conteudo) =>
59 String(conteudo ?? '')
60 .split(/\r?\n/)
61 .filter(linha => MARCA.test(linha))
62 .map(linha => semControle(linha).trim().slice(0, 200))
63 .slice(0, 3);
64
65const plural = (n, um, varios) => n + ' ' + (n === 1 ? um : varios);
66
67// `Bash x12 (2 erros), Edit x3, Write x1 (1 negada)`: cada ferramenta do turno, na ordem
68// em que apareceu, com as falhas. Entrada: [{ tool, deny, isError }].
69export const resumirFerramentas = (ferramentas) => {
70 const porNome = new Map();
71 for (const f of ferramentas ?? []) {
72 const nome = String((f && f.tool) || '?');
73 const c = porNome.get(nome) ?? { n: 0, erros: 0, negadas: 0 };
74 c.n += 1;
75 if (f && f.deny) c.negadas += 1;
76 else if (f && f.isError) c.erros += 1;
77 porNome.set(nome, c);
78 }
79 return [...porNome]
80 .map(([nome, c]) => {
81 const falhas = [];
82 if (c.erros > 0) falhas.push(plural(c.erros, 'erro', 'erros'));
83 if (c.negadas > 0) falhas.push(plural(c.negadas, 'negada', 'negadas'));
84 return nome + ' x' + c.n + (falhas.length > 0 ? ' (' + falhas.join(', ') + ')' : '');
85 })
86 .join(', ');
87};
88
89// O que o segundo modelo (checker) le: o pedido, as ferramentas do turno com as falhas, o fim
90// do relato e os itens ainda abertos. Em portugues; ate 3 linhas de pendencia nova, uma linha
91// `RESOLVIDO <id>: <evidencia>` por item aberto que o turno resolveu, ou a palavra NENHUM.
92export const montarPromptChecker = ({ pedido, relato, ferramentas, abertos }) =>
93 [
94 'Voce compara o que a pessoa pediu a um assistente de codigo com o relato final dele e com as ferramentas que ele usou.',
95 'Liste cada coisa que o pedido claramente exigia e que o relato mostra que nao foi feita, foi adiada ou foi feita so em parte.',
96 'No maximo 3 linhas, cada uma com menos de 20 palavras e comecando por um verbo. Sem numeracao e sem comentario.',
97 'Pergunta de decisao que o relato faz a pessoa (Q1, Q2...) nao e pendencia: nao liste.',
98 'Para cada item de PENDENCIAS ABERTAS que o pedido, o relato ou as ferramentas mostram feito, respondido ou que deixou de valer, escreva uma linha RESOLVIDO <id>: <evidencia em ate 8 palavras>.',
99 'Se nada ficou por fazer e nada foi resolvido, responda apenas com a palavra NENHUM.',
100 '',
101 'PENDENCIAS ABERTAS:',
102 (abertos ?? []).map(i => i.id + ': ' + String(i.texto ?? '').slice(0, 200)).join('\n') || '(nenhuma)',
103 '',
104 'PEDIDO:',
105 String(pedido ?? '').slice(0, 4000),
106 '',
107 'FERRAMENTAS DO TURNO:',
108 resumirFerramentas(ferramentas) || '(nenhuma)',
109 '',
110 'RELATO FINAL:',
111 String(relato ?? '').slice(-6000),
112 ].join('\n');
113
114const RESOLVIDO = /^[\s*-]*RESOLVIDO\s+D?(\d+)\b/i;
115
116// A resposta do checker em itens: ate 3, sem numeracao, sem a palavra NENHUM e sem as linhas
117// RESOLVIDO, que nao comem a cota dos novos.
118export const lerRespostaChecker = (texto) =>
119 String(texto ?? '')
120 .split('\n')
121 .filter(linha => !RESOLVIDO.test(linha))
122 .map(linha => semControle(linha).replace(/^[\s\d.)*-]+/, '').trim().slice(0, 200))
123 .filter(linha => linha !== '' && !['nenhum', 'none'].includes(linha.replace(/[.\s]+/g, '').toLowerCase()))
124 .slice(0, 3);
125
126// Os ids que o checker deu por resolvidos, so os de `idsAbertos`: o id vem do modelo.
127export const lerResolvidosChecker = (texto, idsAbertos) => {
128 const abertos = new Set(idsAbertos ?? []);
129 const ids = String(texto ?? '')
130 .split('\n')
131 .map(linha => RESOLVIDO.exec(linha))
132 .filter(m => m !== null)
133 .map(m => Number(m[1]))
134 .filter(id => abertos.has(id));
135 return [...new Set(ids)];
136};
137
138// Rascunho do prompt do botao "Faz agora". Nunca e enviado sozinho: so preenche.
139export const rascunhoFazAgora = (frase) =>
140 'Voce deixou isto para depois: "' + String(frase ?? '').slice(0, 200) + '". Faça agora e me diga o que rodou para provar.';
141hooks/faixa-puro.mjs 64 lines1// Biblioteca de largura em celulas de terminal: ES module sem Node, sem acesso a ambiente.
2// Guardou o nome da faixa de foco que o mod desenhava antes da barra de sessao (o foco, as
3// linhas de fluxo e as Q sairam, D2); hoje so serve a hooks/painel-puro.mjs: `largura`,
4// `cortar` e `semControle`. Renomear o arquivo e escopo a parte.
5
6// Largura em celulas de terminal: emoji e CJK valem 2; combinantes e seletores de
7// variacao valem 0. Um caractere seguido de U+FE0F (apresentacao de emoji) vale 2.
8const LARGOS = [
9 [0x1100, 0x115f], [0x2e80, 0xa4cf], [0xac00, 0xd7a3], [0xf900, 0xfaff],
10 [0xfe30, 0xfe6f], [0xff00, 0xff60], [0xffe0, 0xffe6],
11 [0x231a, 0x231b], [0x23e9, 0x23ec], [0x23f0, 0x23f0], [0x23f3, 0x23f3],
12 [0x25fd, 0x25fe], [0x2614, 0x2615], [0x2648, 0x2653], [0x267f, 0x267f],
13 [0x2693, 0x2693], [0x26a1, 0x26a1], [0x26aa, 0x26ab], [0x26bd, 0x26be],
14 [0x26c4, 0x26c5], [0x26ce, 0x26ce], [0x26d4, 0x26d4], [0x26ea, 0x26ea],
15 [0x26f2, 0x26f3], [0x26f5, 0x26f5], [0x26fa, 0x26fa], [0x26fd, 0x26fd],
16 [0x2705, 0x2705], [0x270a, 0x270b], [0x2728, 0x2728], [0x274c, 0x274c],
17 [0x274e, 0x274e], [0x2753, 0x2755], [0x2757, 0x2757], [0x2795, 0x2797],
18 [0x27b0, 0x27b0], [0x27bf, 0x27bf], [0x2b1b, 0x2b1c], [0x2b50, 0x2b50],
19 [0x2b55, 0x2b55], [0x1f004, 0x1f004], [0x1f0cf, 0x1f0cf], [0x1f18e, 0x1f18e],
20 [0x1f191, 0x1f19a], [0x1f200, 0x1f64f], [0x1f680, 0x1f6ff], [0x1f7e0, 0x1f7eb],
21 [0x1f900, 0x1faff], [0x20000, 0x3fffd],
22];
23const ZERO = [
24 [0x0300, 0x036f], [0x1ab0, 0x1aff], [0x1dc0, 0x1dff], [0x200b, 0x200f],
25 [0x20d0, 0x20ff], [0xfe00, 0xfe0f], [0xfe20, 0xfe2f], [0xe0100, 0xe01ef],
26];
27const dentro = (tabela, c) => tabela.some(([a, b]) => c >= a && c <= b);
28
29function larguraDe(c, seguinte) {
30 if (dentro(ZERO, c)) return 0;
31 if (dentro(LARGOS, c) || seguinte === 0xfe0f) return 2;
32 return 1;
33}
34
35/** @param {unknown} str */
36export function largura(str) {
37 const cps = Array.from(String(str ?? ''), ch => ch.codePointAt(0));
38 let total = 0;
39 for (let i = 0; i < cps.length; i++) total += larguraDe(cps[i], cps[i + 1]);
40 return total;
41}
42
43export function cortar(str, cols) {
44 if (largura(str) <= cols) return str;
45 if (cols < 1) return '';
46 const cps = Array.from(str);
47 let saida = '';
48 let w = 0;
49 for (let i = 0; i < cps.length; i++) {
50 const c = larguraDe(cps[i].codePointAt(0), cps[i + 1] ? cps[i + 1].codePointAt(0) : 0);
51 if (w + c > cols - 1) break;
52 saida += cps[i];
53 w += c;
54 }
55 return saida + '…';
56}
57
58// Texto que vem do modelo (nome de subagente, titulo): ESC (sequencia ANSI), outros controles
59// C0/C1 e override bidi nunca chegam crus ao terminal.
60const CONTROLES = /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028\u2029\u202a-\u202e\u2060-\u2069\ufeff]/g;
61export function semControle(s) {
62 return String(s ?? '').replace(CONTROLES, ' ');
63}
64hooks/mapa-puro.mjs 104 lines1// Logica pura do mapa da sessao: o que foi escrito, que skill rodou, que servico MCP
2// e que subagente entrou. Sem `$`, sem Node, sem relogio: o mod chama com valores.
3// D16: so Edit, Write e NotebookEdit contam como escrita; escrita por Bash fica fora.
4
5import { semControle } from './faixa-puro.mjs';
6
7export const ESCRITORAS = new Set(['Edit', 'Write', 'NotebookEdit']);
8export const TETO = 50;
9
10// Controles C0/C1 e override bidi nunca chegam crus ao terminal: a funcao e a de faixa-puro.mjs.
11export { semControle };
12
13/** Caminho escrito pela chamada, ou null (leitura, Bash e o resto nao escrevem). */
14export function escritaDe(e) {
15 if (!ESCRITORAS.has(e.tool)) return null;
16 const c = e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path;
17 return typeof c === 'string' && c !== '' ? semControle(c) : null;
18}
19
20/** Servidor de `mcp__<servidor>__<ferramenta>`, ou null. */
21export function servidorDe(tool) {
22 const m = /^mcp__(.+?)__.+$/.exec(String(tool ?? ''));
23 return m ? semControle(m[1]) : null;
24}
25
26export function mapaVazio() {
27 return { arquivos: [], skills: [], servicos: [], subagentes: [] };
28}
29
30// Estoura o teto: sai a entrada mais antiga sem desvio; so desvio sobrando, a mais antiga.
31function aparar(lista) {
32 const l = lista.slice();
33 while (l.length > TETO) {
34 const i = l.findIndex(x => typeof x === 'object' && !x.desvio);
35 l.splice(i < 0 ? 0 : i, 1);
36 }
37 return l;
38}
39
40function incluir(lista, nome) {
41 if (nome === '' || lista.includes(nome)) return { lista, novo: false };
42 return { lista: aparar([...lista, nome]), novo: true };
43}
44
45/**
46 * Evento: o `tool.call` cru (`tool` mais argumentos), `{ agente: true, description }`
47 * para o `agent.spawn`, ou `{ desvio: true, caminho }` para marcar arquivo fora do plano.
48 * Devolve `{ mapa, novo }`; `novo` so e true na 1a vez que a entrada aparece.
49 */
50export function registrar(mapa, evento) {
51 const m = { ...mapaVazio(), ...mapa };
52 if (evento && evento.agente === true) {
53 const r = incluir(m.subagentes, semControle(evento.description).trim());
54 return { mapa: { ...m, subagentes: r.lista }, novo: r.novo };
55 }
56 if (evento && evento.desvio === true) {
57 const caminho = semControle(evento.caminho);
58 const existe = m.arquivos.some(a => a.caminho === caminho);
59 const arquivos = existe
60 ? m.arquivos.map(a => (a.caminho === caminho ? { ...a, desvio: true } : a))
61 : aparar([...m.arquivos, { caminho, desvio: true }]);
62 return { mapa: { ...m, arquivos }, novo: !existe };
63 }
64 const caminho = escritaDe(evento);
65 if (caminho !== null) {
66 if (m.arquivos.some(a => a.caminho === caminho)) return { mapa: m, novo: false };
67 return { mapa: { ...m, arquivos: aparar([...m.arquivos, { caminho, desvio: false }]) }, novo: true };
68 }
69 if (evento.tool === 'Skill' && typeof evento.skill === 'string') {
70 const r = incluir(m.skills, semControle(evento.skill).trim());
71 return { mapa: { ...m, skills: r.lista }, novo: r.novo };
72 }
73 const servidor = servidorDe(evento.tool);
74 if (servidor !== null) {
75 const r = incluir(m.servicos, servidor);
76 return { mapa: { ...m, servicos: r.lista }, novo: r.novo };
77 }
78 return { mapa: m, novo: false };
79}
80
81/**
82 * Troca a chave de um arquivo do mapa: `de` (o caminho absoluto da escrita, que a falha aberta
83 * e a fila cheia registram) vira `para` (o relativo que o veredito devolveu), no mesmo lugar.
84 * Se `para` ja existe, a entrada de `de` some e o vermelho de qualquer uma das duas fica.
85 */
86export function trocarCaminho(mapa, de, para) {
87 const m = { ...mapaVazio(), ...mapa };
88 const origem = semControle(de);
89 const destino = semControle(para);
90 const i = m.arquivos.findIndex(a => a.caminho === origem);
91 if (i < 0 || origem === destino) return m;
92 const velho = m.arquivos[i];
93 const j = m.arquivos.findIndex(a => a.caminho === destino);
94 const arquivos = m.arquivos.slice();
95 if (j < 0) {
96 arquivos[i] = { ...velho, caminho: destino };
97 } else {
98 arquivos[j] = { ...arquivos[j], desvio: arquivos[j].desvio || velho.desvio };
99 arquivos.splice(i, 1);
100 }
101 return { ...m, arquivos };
102}
103
104hooks/painel-puro.mjs 163 lines1// Adaptado do terminal-desk 0.2.1 (licenca MIT, titular ClariSortAi); texto da licenca e
2// origem em NOTICE, na raiz do plugin.
3// Logica pura do painel do mod (barra de sessao e pane): preco e cache de prompt, fatias
4// do contexto, ritmo, formatacao e as figuras da barra. ES module sem Node, sem `$` e sem
5// relogio: o instante entra por argumento (`agora`, em ms), nunca por leitura.
6// Largura em celulas vem de faixa-puro.mjs (nunca copiada aqui).
7
8import { largura, cortar, semControle } from './faixa-puro.mjs';
9
10const MINUTO = 60_000;
11
12// Precos de lista da API em dolares por milhao de tokens de entrada e a parcela que uma
13// leitura de cache custa dela. A primeira linha que casa vence (cotacoes de 2026-09-25,
14// nao reconferidas).
15export const PRECOS = [
16 { casa: 'fable-5-1', entrada: 10, leitura: 0.025 },
17 { casa: 'mythos-5-1', entrada: 10, leitura: 0.025 },
18 { casa: 'fable', entrada: 10, leitura: 0.1 },
19 { casa: 'mythos', entrada: 10, leitura: 0.1 },
20 { casa: 'opus-5-5', entrada: 4, leitura: 0.05 },
21 { casa: 'opus', entrada: 5, leitura: 0.1 },
22 { casa: 'sonnet-5', entrada: 2, leitura: 0.1 },
23 { casa: 'sonnet', entrada: 3, leitura: 0.1 },
24 { casa: 'haiku', entrada: 1, leitura: 0.1 },
25];
26
27export function precoDe(modelo) {
28 const nome = String(modelo ?? '');
29 return PRECOS.find((p) => nome.includes(p.casa)) ?? null;
30}
31
32export function compacto(n) {
33 if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + 'M';
34 return n >= 1000 ? (n / 1000).toFixed(1) + 'K' : String(n);
35}
36
37export function dinheiro(usd) {
38 if (typeof usd !== 'number' || !Number.isFinite(usd)) return '--';
39 return usd > 0 && usd < 0.005 ? '<$0.01' : '$' + usd.toFixed(2);
40}
41
42// Minutos e segundos que faltam, para a contagem se ver andar.
43export function restante(ms) {
44 const s = Math.ceil(Math.max(0, ms) / 1000);
45 return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
46}
47
48// Chamadas de ferramenta no ultimo minuto antes de `agora`.
49export function ritmoPorMinuto(carimbos, agora) {
50 return (Array.isArray(carimbos) ? carimbos : []).filter((t) => agora - t <= MINUTO).length;
51}
52
53// Cache de prompt: quente enquanto a ultima requisicao da sessao principal comecou dentro
54// da vida do cache (reenviar e leitura barata); frio depois (a janela inteira e escrita de
55// novo, a 1,25x no cache de 5 min e a 2x no de 1 h).
56export function cacheDe({ modelo, ctxTokens, ultimaRequisicaoMs, agora, ttlMs }) {
57 const restanteMs = ultimaRequisicaoMs === 0 ? 0 : Math.max(0, ultimaRequisicaoMs + ttlMs - agora);
58 const quente = restanteMs > 0;
59 const preco = precoDe(modelo);
60 const semDado = preco === null || typeof ctxTokens !== 'number';
61 const milhoes = (ctxTokens ?? 0) / 1_000_000;
62 const escrita = ttlMs > 5 * MINUTO ? 2 : 1.25;
63 return {
64 quente,
65 restanteMs,
66 custoQuente: semDado ? null : milhoes * preco.entrada * preco.leitura,
67 custoFrio: semDado ? null : milhoes * preco.entrada * escrita,
68 };
69}
70
71// Celulas por fatia pelo maior resto: a barra tem exatamente `larguraTotal` celulas.
72export function fatias(lista, larguraTotal) {
73 const total = lista.reduce((s, f) => s + f.tokens, 0);
74 if (total === 0) return lista.map(() => 0);
75 const exato = lista.map((f) => (f.tokens / total) * larguraTotal);
76 const celulas = exato.map(Math.floor);
77 let sobra = larguraTotal - celulas.reduce((s, n) => s + n, 0);
78 const ordem = exato.map((x, i) => ({ i, resto: x - Math.floor(x) })).sort((a, b) => b.resto - a.resto);
79 for (const { i } of ordem) {
80 if (sobra <= 0) break;
81 celulas[i] += 1;
82 sobra -= 1;
83 }
84 return celulas;
85}
86
87// Nome de subagente vem do modelo: controles e override bidi nunca chegam crus ao terminal.
88export function rotuloSubagente(nome, cols = 24) {
89 return cortar(semControle(String(nome ?? '')), cols);
90}
91
92const SEPARADOR = ' ';
93
94function barraContexto(pct) {
95 const n = 7;
96 const pos = Math.min(n - 1, Math.max(0, Math.round((pct / 100) * (n - 1))));
97 let b = '';
98 for (let i = 0; i < n; i++) b += i === pos ? '●' : i < pos ? '━' : '─';
99 return b;
100}
101
102// stats: { trabalhando, tokens, custoUsd, ctxPct, ctxTokens, modelo, ttlMs,
103// ultimaRequisicaoMs, agora, deixado, carimbos, subagentes, turnos, erros }
104// relogio: linha pronta de `linhaRelogio` (string) ou null.
105// Devolve as figuras que cabem em `cols`, na ordem de exibicao: [{ id, texto, largura }].
106// Quando aperta, caem da direita; o estado e o relogio ficam por ultimo, o relogio por ultimo.
107export function figurasDaBarra(stats, relogio, cols) {
108 const s = stats ?? {};
109 const agora = s.agora ?? 0;
110 const c = cacheDe({
111 modelo: s.modelo,
112 ctxTokens: s.ctxTokens,
113 ultimaRequisicaoMs: s.ultimaRequisicaoMs ?? 0,
114 agora,
115 ttlMs: s.ttlMs ?? 5 * MINUTO,
116 });
117 const todas = [
118 ['estado', s.trabalhando ? '● trabalhando' : '○ pronto'],
119 ['tokens', 'Tokens ' + compacto(s.tokens ?? 0)],
120 ['custo', 'Custo ' + dinheiro(s.custoUsd ?? null)],
121 ];
122 if (typeof s.ctxPct === 'number') todas.push(['contexto', 'Contexto 0 ' + barraContexto(s.ctxPct) + ' 100 ' + Math.round(s.ctxPct) + '%']);
123 todas.push([
124 'cache',
125 c.quente
126 ? 'Cache ● quente ' + restante(c.restanteMs) + ' · reenvio ' + dinheiro(c.custoQuente) + ' (' + dinheiro(c.custoFrio) + ' se esfriar)'
127 : 'Cache ○ frio · reenvio ' + dinheiro(c.custoFrio),
128 ]);
129 if ((s.deixado ?? 0) > 0) todas.push(['deixado', 'Deixado ' + s.deixado]);
130 todas.push(['ritmo', 'Ferram./min ' + ritmoPorMinuto(s.carimbos, agora)]);
131 todas.push(['subagentes', 'Subagentes ' + (s.subagentes ?? 0)]);
132 todas.push(['turnos', 'Turnos ' + (s.turnos ?? 0)]);
133 todas.push(['erros', 'Erros ' + (s.erros ?? 0)]);
134 if (relogio) todas.push(['relogio', relogio]);
135
136 const figuras = todas.map(([id, texto]) => {
137 const t = semControle(String(texto));
138 return { id, texto: t, largura: largura(t) };
139 });
140 // Ordem em que as figuras tentam entrar: o relogio primeiro (e o ultimo a cair), o estado
141 // depois, o resto na ordem de exibicao. Cada uma entra se couber; a que nao cabe nao
142 // impede as seguintes. As demais so entram com o estado ja dentro: estreito demais para os
143 // dois prioritarios juntos, sobra o relogio sozinho, nunca uma figura menor no lugar do estado.
144 const prioridade = ['relogio', 'estado'];
145 const ordemDeEntrada = [...prioridade, ...figuras.map((f) => f.id).filter((id) => !prioridade.includes(id))];
146 const mantidas = new Set();
147 let usado = 0;
148 for (const id of ordemDeEntrada) {
149 const f = figuras.find((x) => x.id === id);
150 if (!f) continue;
151 if (!prioridade.includes(id) && !mantidas.has('estado')) continue;
152 const custo = f.largura + (mantidas.size > 0 ? largura(SEPARADOR) : 0);
153 if (usado + custo > cols) continue;
154 mantidas.add(id);
155 usado += custo;
156 }
157 if (mantidas.size === 0 && figuras.length > 0) {
158 const t = cortar(figuras[0].texto, cols);
159 return [{ id: figuras[0].id, texto: t, largura: largura(t) }];
160 }
161 return figuras.filter((f) => mantidas.has(f.id));
162}
163hooks/relogio-puro.mjs 98 lines1// Logica pura do relogio do mod (jornada e janela parada). ES module sem Node e sem
2// o objeto do engine: o relogio entra por argumento (`agora`, em ms), nunca por leitura.
3// Contrato: docs/rainforest/planos/2026-10-03-mod-jornada-relogio.md.
4
5export const LIMITE_EFETIVA_MIN = 540;
6export const HORA_NOITE = 19;
7export const HORA_FIM_MADRUGADA = 5;
8export const JANELA_MSG_MIN = 30;
9export const OCIOSIDADE_PADRAO_MIN = 45;
10
11const pad2 = (n) => String(n).padStart(2, '0');
12
13// Mesma forma de `hhmm` em scripts/jornada.cjs: `9h12`, `45 min`.
14export function hhmm(minutos) {
15 const total = Math.round(minutos);
16 const h = Math.floor(total / 60);
17 const m = total % 60;
18 return h ? h + 'h' + pad2(m) : m + ' min';
19}
20
21export function diaLocal(ms) {
22 const d = new Date(ms);
23 return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
24}
25
26export function horaLocal(ms) {
27 const d = new Date(ms);
28 return d.getHours() + 'h' + pad2(d.getMinutes());
29}
30
31// Ultimo segmento do caminho; o cwd real do Windows vem com barra invertida.
32function pastaDe(cwd) {
33 const partes = String(cwd || '').replace(/[\\/]+$/, '').split(/[\\/]/);
34 return partes[partes.length - 1] || '';
35}
36
37// jornada: { efetiva_min, ultimo_ms } | null
38// sessoes: { ociosidade_min, janelas: [{ cwd, desde }] } | null
39export function avaliarRelogio({ jornada, sessoes, agora }) {
40 let j = null;
41 if (jornada) {
42 const { efetiva_min, ultimo_ms } = jornada;
43 const hora = new Date(agora).getHours();
44 const longa = efetiva_min > LIMITE_EFETIVA_MIN;
45 const noite = (hora >= HORA_NOITE || hora < HORA_FIM_MADRUGADA) && agora - ultimo_ms <= JANELA_MSG_MIN * 60000;
46 const mesmoDia = diaLocal(ultimo_ms) === diaLocal(agora);
47 if (mesmoDia && (longa || noite)) j = { efetiva_min, hora: horaLocal(agora), dia: diaLocal(agora) };
48 }
49
50 let parada = null;
51 if (sessoes && Array.isArray(sessoes.janelas)) {
52 const limite = typeof sessoes.ociosidade_min === 'number' ? sessoes.ociosidade_min : OCIOSIDADE_PADRAO_MIN;
53 const acima = sessoes.janelas
54 .filter((w) => (agora - w.desde) / 60000 > limite)
55 .sort((a, b) => a.desde - b.desde);
56 if (acima.length > 0) {
57 const w = acima[0];
58 parada = {
59 cwd: w.cwd,
60 pasta: pastaDe(w.cwd),
61 min: Math.floor((agora - w.desde) / 60000),
62 outras: acima.length - 1,
63 };
64 }
65 }
66 return { jornada: j, parada };
67}
68
69export function linhaRelogio(r) {
70 if (!r || (!r.jornada && !r.parada)) return null;
71 const partes = [];
72 if (r.jornada) partes.push('jornada ' + hhmm(r.jornada.efetiva_min) + ' · ' + r.jornada.hora);
73 if (r.parada) {
74 const mais = r.parada.outras > 0 ? ' (+' + r.parada.outras + ')' : '';
75 partes.push(r.parada.pasta + ' parada há ' + hhmm(r.parada.min) + mais);
76 }
77 return '⏰ ' + partes.join(' | ');
78}
79
80// Nunca os minutos: so o dia (jornada) e quem esta mais parada e quantas passam do limite.
81export function assinaturaRelogio(r) {
82 if (!r) return '';
83 const trechos = [];
84 if (r.jornada) trechos.push('j:' + r.jornada.dia);
85 if (r.parada) trechos.push('p:' + r.parada.cwd + ':' + (r.parada.outras + 1));
86 return trechos.join('|');
87}
88
89export function notaJornada(r) {
90 if (!r || !r.jornada) return null;
91 return (
92 'Relógio do mod (regra 8): a jornada efetiva de hoje é ' + hhmm(r.jornada.efetiva_min) +
93 ' e são ' + r.jornada.hora + '. Avalie o aviso da regra 8 neste turno: se o usuário está ' +
94 'produzindo ativamente, avise uma única vez (a hora, um ponto de parada concreto, a checagem de corpo); ' +
95 'se está delegando em projeto de descanso, não avise. Esta nota não se repete hoje.'
96 );
97}
98hooks/abertura-mod-puro.mjs 149 lines1// Logica do mod da abertura, pura: ES module sem Node. Nao recebe o `$`: o engine nao
2// aceita `$` passado como argumento (so `$.substantivo.evento(...)` no ponto de chamada,
3// medido por `claude plugin validate`). Recebe `io`, tres funcoes que `register.ts` tira
4// do `$`. `register.ts` so liga os tres hooks a ela; a bateria
5// `hooks/testa-mod-abertura.cjs` carrega o proprio register.ts com um `$` falso.
6//
7// O que o mod faz: monta UMA vez por sessao a abertura (foco com as regras + memoria)
8// rodando os dois geradores com `--destino mod`, acrescenta o texto como a secao
9// `rainforest-mind:abertura` do system prompt (sem o teto de entrega do hook
10// SessionStart) e tira do `classic.SessionStart` as entradas dos mesmos dois hooks, para
11// o modelo nao receber a abertura duas vezes. Por que o canal e o prompt, e nao o hook,
12// esta em hooks/abertura-mod.json e nos geradores.
13//
14// CONTA COM sec-default (login org): o built-in `cc-plugin-sec-default` barra, por politica
15// da organizacao, `prompt.compose` e `classic.SessionStart` de plugin tier user. O mod nao
16// tenta entregar a abertura por outro canal: a abertura chega so pelo nucleo do SessionStart
17// que o harness deixar passar. Regras inteiras ali pedem liberacao do admin da organizacao.
18//
19// FALHA ABERTA: se a montagem falha (gerador com exit != 0, JSON invalido, timeout,
20// additionalContext ausente), a secao nao e acrescentada E nenhuma entrada do
21// SessionStart e removida. A sessao nunca fica sem as regras: no pior caso elas chegam
22// pelo canal de sempre.
23
24/**
25 * @typedef {{
26 * rodar: (argv: string[], init: object) => Promise<any>,
27 * cwd: () => Promise<string>,
28 * raiz: string,
29 * }} Io
30 */
31
32export const ID_SECAO = 'rainforest-mind:abertura';
33
34// 60 s: o hook do foco ja levou 6 a 8 s (issue #243); o teto padrao do `$.process.run`
35// e 30 s.
36export const TIMEOUT_MS = 60000;
37
38export const GERADORES = [
39 { nome: 'foco', script: 'foco-session-start.cjs', obrigatorio: true },
40 { nome: 'memoria', script: 'memoria-session-start.cjs', obrigatorio: false },
41];
42
43// Como o additionalContext de cada hook COMECA. Sao os textos reais emitidos hoje
44// (hooks/lib/contexto-sessao.cjs e hooks/lib/memoria-sessao.cjs); a bateria os confere
45// contra a saida real dos geradores. Entrada que nao comeca por um destes fica: o
46// filtro so remove o que reconhece, e o `codex-transfer-session-start.cjs` (que tambem
47// escreve no SessionStart) nunca casa.
48export const PREFIXOS_FOCO = [
49 'RAINFOREST MIND ATIVO',
50 '⚠️ **FALHA AO CARREGAR AS REGRAS',
51 '⚠️ **INJEÇÃO ACIMA DO ORÇAMENTO',
52];
53export const PREFIXOS_MEMORIA = [
54 '## Memória (corpus residentes)',
55 '⚠️ Memória acima do orçamento',
56 '⚠️ Captura da memória parada',
57 '⚠️ Manutenção da memória falhou',
58];
59const PREFIXOS = [...PREFIXOS_FOCO, ...PREFIXOS_MEMORIA];
60
61/** @param {unknown} texto */
62export function ehEntradaDaAbertura(texto) {
63 if (typeof texto !== 'string') return false;
64 const t = texto.trimStart();
65 return PREFIXOS.some(p => t.startsWith(p));
66}
67
68/**
69 * Roda um gerador e devolve o `additionalContext` dele. Lanca em qualquer falha.
70 * @param {Io} io
71 * @param {string} cwd
72 * @param {{ nome: string, script: string, obrigatorio: boolean }} g
73 * @returns {Promise<string>}
74 */
75async function gerar(io, cwd, g) {
76 const script = `${io.raiz}/hooks/${g.script}`;
77 const r = await io.rodar(['node', script, '--destino', 'mod'], {
78 cwd: io.raiz,
79 env: { CLAUDE_PROJECT_DIR: cwd },
80 timeoutMs: TIMEOUT_MS,
81 });
82 if (r.exitCode !== 0) throw new Error(`${g.nome}: exit ${r.exitCode}`);
83 if (r.isStdoutTruncated) throw new Error(`${g.nome}: saida truncada`);
84 const ctx = JSON.parse(r.stdout)?.hookSpecificOutput?.additionalContext;
85 if (typeof ctx !== 'string') throw new Error(`${g.nome}: sem additionalContext`);
86 if (g.obrigatorio && !ctx.trim()) throw new Error(`${g.nome}: additionalContext vazio`);
87 return ctx.trim();
88}
89
90/**
91 * Monta o texto da secao: additionalContext do foco, depois o da memoria, sem os
92 * systemMessage. `null` quando qualquer gerador falha.
93 * @param {Io} io
94 * @returns {Promise<string | null>}
95 */
96async function montar(io) {
97 try {
98 const cwd = await io.cwd();
99 const partes = await Promise.all(GERADORES.map(g => gerar(io, cwd, g)));
100 const texto = partes.filter(Boolean).join('\n\n');
101 return texto || null;
102 } catch {
103 return null;
104 }
105}
106
107/**
108 * Um estado por mod carregado: a promessa da montagem. Memoizar a PROMESSA (e nao o
109 * texto) resolve a ordem dos eventos: o `classic.SessionStart` pode chegar antes do
110 * primeiro `prompt.compose`, e os dois esperam a mesma montagem em vez de rodar os
111 * geradores duas vezes, e o filtro do SessionStart so age sabendo que ela deu certo.
112 */
113export function criarAbertura() {
114 /** @type {Promise<string | null> | null} */
115 let memo = null;
116
117 return {
118 /**
119 * A montagem (uma por abertura).
120 * @param {Io} io
121 */
122 obter(io) {
123 if (memo) return memo;
124 memo = montar(io);
125 return memo;
126 },
127
128 /** `/clear` e `/resume` encerram a conversa sem `session.start`: a proxima abertura e remontada. */
129 aoEncerrar(/** @type {string} */ reason) {
130 if (reason === 'clear' || reason === 'resume') memo = null;
131 },
132
133 /** Resultado do `prompt.compose` com a secao no fim; `r` intacto sem texto. */
134 comSecao(/** @type {any} */ r, /** @type {string | null} */ texto) {
135 if (!texto || !r || !Array.isArray(r.sections)) return r;
136 if (r.sections.some((/** @type {any} */ s) => s.id === ID_SECAO)) return r;
137 return { ...r, sections: [...r.sections, { id: ID_SECAO, text: texto, scope: 'session' }] };
138 },
139
140 /** Resultado do `classic.SessionStart` sem as entradas da abertura; `r` intacto sem texto. */
141 semAbertura(/** @type {any} */ r, /** @type {string | null} */ texto) {
142 if (!texto || !r || !Array.isArray(r.additionalContext)) return r;
143 const ficam = r.additionalContext.filter((/** @type {any} */ x) => !ehEntradaDaAbertura(x));
144 if (ficam.length === r.additionalContext.length) return r;
145 return { ...r, additionalContext: ficam };
146 },
147 };
148}
149types/index.d.ts 105 lines1// Contrato de estado do mod (`$.state`, plugin `rainforest-mind`). O engine confere cada
2// chave que o modulo nomeia contra este arquivo. Forma dos dados: scripts/faixa-dados.cjs
3// (faixaDados) e hooks/mod.tsx (painelStats).
4export type RainforestMindFaixaFluxo = {
5 slug: string
6 titulo?: string
7 etapa: string
8 tarefas_ok: number | null
9 tarefas: number | null
10 em_voo: string[]
11 criado_em?: string
12 worktree?: string
13}
14
15export type RainforestMindFaixaDados = {
16 fluxos: RainforestMindFaixaFluxo[]
17}
18
19export type RainforestMindRelogioJornada = { efetiva_min: number; ultimo_ms: number }
20
21export type RainforestMindRelogioSessoes = {
22 ociosidade_min: number
23 janelas: { cwd: string; desde: number }[]
24}
25
26// Uma fatia do contexto, como o `session.usage` a devolve (so as categorias `used`).
27export type RainforestMindPainelFatia = { nome: string; tokens: number; tipo: string }
28
29// Um subagente visto pelo mod. `fimEm` nulo = ainda em andamento (ou sem fim visto).
30export type RainforestMindPainelAgente = {
31 id: string
32 rotulo: string
33 tipo: string
34 modelo: string | null
35 iniciadoEm: number
36 fimEm: number | null
37 vistoEm: number
38 criadoAqui: boolean
39 ferramentas: number
40 tokensLidos: number
41 tokensSaida: number
42 falhou: boolean
43}
44
45// Contadores da sessao que a barra e o pane leem. Tempos em ms de `$.clock.now()`.
46export type RainforestMindPainelStats = {
47 turnos: number
48 ferramentas: number
49 falhas: number
50 tokensNovos: number
51 tokensCacheLido: number
52 tokensCacheEscrito: number
53 tokensSaida: number
54 custoUsd: number | null
55 ctxPct: number | null
56 ctxTokens: number | null
57 ctxJanela: number | null
58 fatias: RainforestMindPainelFatia[]
59 carimbos: number[]
60 agentes: RainforestMindPainelAgente[]
61 ultimaRequisicaoMs: number
62 modelo: string | null
63 ttlMs: number
64 medirDeNovo: boolean
65}
66
67// Mapa da sessao (D13): o que foi escrito (por Edit, Write ou NotebookEdit), que skill rodou, que
68// servico MCP respondeu e que subagente entrou. `desvio` = fora dos `arquivos:` do plano do fluxo.
69export type RainforestMindPainelMapa = {
70 arquivos: { caminho: string; desvio: boolean }[]
71 skills: string[]
72 servicos: string[]
73 subagentes: string[]
74}
75
76// "Deixado para depois" (D7): itens (aberto, enviado pelo Faz agora ou resolvido pelo
77// checker), se o checker esta rodando e ligado, o ultimo pedido
78// da pessoa (ate 4000 caracteres) e as ferramentas do turno da sessao principal (nome e falha).
79export type RainforestMindPainelDeixadoOrigem = 'Claude disse' | 'em arquivo' | 'segundo modelo'
80
81export type RainforestMindPainelDeixado = {
82 itens: { id: number; texto: string; origem: RainforestMindPainelDeixadoOrigem; estado: 'aberto' | 'enviado' | 'resolvido' }[]
83 proximo: number
84 checando: boolean
85 checar: boolean
86 pedido: string
87 ferramentas: { tool: string; deny: boolean; isError: boolean }[]
88}
89
90declare module 'claude-code' {
91 interface PluginState {
92 'rainforest-mind': {
93 faixaDados: RainforestMindFaixaDados | null
94 painelStats: RainforestMindPainelStats
95 painelOculto: boolean
96 painelMapa: RainforestMindPainelMapa
97 painelDeixado: RainforestMindPainelDeixado
98 relogioJornada: RainforestMindRelogioJornada | null
99 relogioSessoes: RainforestMindRelogioSessoes | null
100 relogioNotaPendente: string | null
101 relogioNotaEntregue: string | null
102 }
103 }
104}
105