Painel ao vivo do que o Claude e cada ajudante fazem: subagentes, agentes de Workflow, comandos, agy e Codex em árvore, com modelo, tokens, pendências, avisos…

Mods do Claude Code (function hooks) que uso todo dia. · Claude Code mods (function hooks) I use every day.
Seis mods, cada um resolvendo um problema que apareceu no uso real. A interface e as mensagens dos mods estão em português.
Precisa do Claude Code 2.1.287 ou mais novo.
/plugin marketplace add diegocamara89/claude-mods
/plugin install sonnet-por-padrao@claude-mods-diego
Troque sonnet-por-padrao pelo nome de qualquer mod da tabela. Para testar sem instalar: claude --plugin-dir ./mods/<nome>.
| Mod | O que faz | O que acessa | |
|---|---|---|---|
| sonnet-por-padrao | Subagente e agente de script (Workflow) criados sem modelo escrito rodam no Sonnet, em vez de herdar o Opus da conversa. Opus continua valendo quando escrito de propósito. | Reescreve o modelo do subagente e o script do Workflow; grava um registro das decisões (com a descrição de cada subagente) em LOCALAPPDATA no Windows ou TMPDIR//tmp fora dele. | |
| revisor-com-prova | Quando volta o resultado de um revisor (Codex, codex exec, agy, council), anexa ao resultado um lembrete: achado só vale com prova, reproduza antes de corrigir, "aprovado sem achados" é válido. | Só lê o comando; anexa texto ao resultado, que só o modelo vê. Os padrões agy.py e council.py são de skills do autor; sem elas, simplesmente não disparam. | |
| proximos-passos | Depois de cada resposta, o Haiku propõe três próximos pedidos em botões acima da caixa (teclas 1-3). /custos mostra os tokens medidos. | Chama o Haiku (cerca de 1 mil tokens por sugestão, medido); desenha a faixa. | |
| painel-vivo | Painel ao vivo, para ler de relance: uma manchete com Pendências, Atividade e Progresso; fila do que pede você (permissão, pergunta, travado) e avisos; trabalhadores em árvore (subagentes, agentes de Workflow, comandos, agy e Codex aninhados em quem os chamou), cada um com tempo, modelo, tokens e o que faz agora; outras sessões em andamento numa linha; cotas de 5 h e 7 dias. "Explicar" traduz um trabalho para leigo e alimenta um glossário (/glossario). | Envolve cada comando Bash e PowerShell para copiar a saída num arquivo local (tee, em LOCALAPPDATA ou TMPDIR) e marcar onde o agy grava os passos (AGY_EVENTS_DIR); apaga esses arquivos depois de 1 dia; se o rtk estiver instalado, passa o comando por ele antes; guarda um resumo de cada sessão no armazenamento do plugin para as outras sessões verem; lê o título da conversa no transcript com um script Python curto; chama o Haiku só no "Explicar". | |
| lixeira | Nega o apagar de vez e devolve ao Claude o comando que manda para a lixeira do sistema; barra comandos sem volta (git reset --hard, push --force, formatar, esvaziar lixeira, desligar ou reiniciar o computador, Format-Volume, Clear-Disk) até o usuário dizer sim (LIXEIRA_OK=1 no Bash, $env:LIXEIRA_OK=1; no PowerShell). Também pega o apagar pela esteira do PowerShell (`gci pasta \ | Remove-Item`). Temporários, caches e arquivos criados na conversa ficam livres. | Lê o comando e nega quando precisa. No Windows usa o lixeira.ps1 que vem junto; no macOS e no Linux sugere trash ou gio trash. |
| varredura-push | Antes de git push e de publicação pelo gh (no Bash e no PowerShell), confere os commits que vão subir procurando chave, token e senha. Barra o envio se achar. Liberação pontual com VARREDURA_OK=1. | Põe python varredura.py && antes do push. Usa o gitleaks se estiver no PATH; sem ele, regras de reserva. |
Revisor por LLM tende a achar defeito em trabalho correto quando o pedido manda "achar o que quebra": num estudo de 2026, o GPT-4o reprovou 73% de código correto com esse enquadramento, contra 26% com um pedido simples (arXiv 2603.00539). Do outro lado, quem recebe a crítica aceita sem conferir: diante de 48 relatórios falsos, o Claude Code rejeitou 1 (arXiv 2604.11950). Exigir prova reverteu isso para 85–96% de rejeição. O mod coloca essa regra exatamente onde ela faz falta: no resultado do revisor, e não em toda mensagem.
painel-vivo: a saída ao vivo usa bash (no Windows, o Git Bash que o Claude Code já usa); o nome das outras sessões precisa de python no PATH; feito para o app desktop (no terminal, desenha uma versão só texto). O Codex não informa tokens: o painel mostra só o modelo, quando o comando o escolhe.varredura-push: precisa de Python (python no Windows, python3 no macOS e no Linux).sonnet-por-padrao: não vê script de Workflow rodado a partir de arquivo (scriptPath) nem Workflow salvo por nome.revisor-com-prova: também dispara quando o agy é usado só para extrair dados; o lembrete começa com "se este resultado traz crítica".A leitura de comandos da lixeira (aspas, sudo/env, alvos seguros) veio de ideias do launch-codes, da OneWave AI (MIT).
Licença: MIT.
Six mods, each fixing a problem that came up in real use. The mods' UI and messages are in Portuguese.
Requires Claude Code 2.1.287 or later.
/plugin marketplace add diegocamara89/claude-mods
/plugin install sonnet-por-padrao@claude-mods-diego
Replace sonnet-por-padrao with any mod name from the table. To try one without installing: claude --plugin-dir ./mods/<name>.
| Mod | What it does | What it touches | |
|---|---|---|---|
| sonnet-por-padrao ("Sonnet by default") | Subagents and Workflow script agents created without an explicit model run on Sonnet instead of inheriting the session's Opus. An explicit opus is kept. | Rewrites the subagent's model and the Workflow script; logs its decisions (with each subagent's description) to LOCALAPPDATA on Windows or TMPDIR//tmp elsewhere. | |
| revisor-com-prova ("reviewer with evidence") | When a reviewer returns (Codex, codex exec, agy, council), attaches a reminder to the result: a finding counts only with evidence, reproduce before fixing, "approved with no findings" is valid. | Reads the command only; attaches model-only text to the result. The agy.py and council.py patterns come from the author's own skills; without them they simply never fire. | |
| proximos-passos ("next steps") | After each answer, Haiku proposes three next requests as buttons above the prompt (keys 1-3). /custos shows measured token use. | Calls Haiku (about 1k tokens per suggestion, measured); draws the band. | |
| painel-vivo ("live pane") | Glanceable live pane: a headline with Pending, Activity and Progress; a queue of what needs you (permission, question, stuck worker) and warnings; workers as a tree (subagents, Workflow agents, commands, agy and Codex nested under whoever called them), each with elapsed time, model, tokens and what it is doing now; other busy sessions in one line; 5-hour and 7-day rate limits. "Explicar" explains a job in plain language and feeds a glossary (/glossario). | Wraps each Bash and PowerShell command to copy its output to a local file (tee, in LOCALAPPDATA or TMPDIR) and to tell agy where to write its steps (AGY_EVENTS_DIR); deletes those files after 1 day; if rtk is installed, runs the command through it first; keeps a per-session summary in the plugin store so other sessions can see it; reads the conversation title from the transcript with a short Python script; calls Haiku only on "Explicar". | |
| lixeira ("trash") | Denies permanent deletes and hands Claude the command that moves the files to the system trash; blocks no-way-back commands (git reset --hard, push --force, format, emptying the trash, shutting down or restarting the computer, Format-Volume, Clear-Disk) until the user says yes (LIXEIRA_OK=1 in Bash, $env:LIXEIRA_OK=1; in PowerShell). It also catches deletes through a PowerShell pipeline (`gci folder \ | Remove-Item`). Temp folders, caches and files written in the session stay free. | Reads the command and denies when needed. On Windows it uses the bundled lixeira.ps1; on macOS and Linux it suggests trash or gio trash. |
| varredura-push ("push scan") | Before git push and gh publishing (in Bash and PowerShell), scans the commits about to leave for keys, tokens and passwords, and blocks the push on a hit. One-off override with VARREDURA_OK=1. | Prepends python varredura.py && to the push. Uses gitleaks when on PATH; fallback rules otherwise. |
LLM reviewers find defects in correct work when told to "find what breaks": in a 2026 study GPT-4o rejected 73% of correct code under that framing, against 26% with a plain prompt (arXiv 2603.00539). On the receiving side, critique gets accepted unchecked: given 48 false reports, Claude Code rejected 1 (arXiv 2604.11950). Requiring evidence moved that to 85–96% rejection. The mod puts the rule exactly where it is needed: on the reviewer's result, not on every message.
painel-vivo: live output uses bash (on Windows, the Git Bash Claude Code already uses); other sessions' names need python on PATH; built for the desktop app (the terminal gets a text-only version). Codex does not report tokens: the pane shows only its model, when the command picks one.varredura-push: needs Python (python on Windows, python3 on macOS and Linux).sonnet-por-padrao: does not see Workflow scripts run from a file (scriptPath) or saved Workflows run by name.revisor-com-prova: also fires when agy is used only for extraction; the reminder starts with "if this result carries critique".The command parsing in lixeira (quotes, sudo/env, safe targets) builds on ideas from launch-codes by OneWave AI (MIT).
License: MIT.
hooks/register.tsx 9 lines1import type { Register } from 'claude-code'
2
3import { ligar } from './coleta'
4
5// Entrada do mod. Os ganchos, o relógio, os comandos (/painel, /glossario) e o render do painel
6// moram em coleta.ts: o motor só segue o $ para funções do próprio arquivo, nunca através de import, então
7// quem recebe o $ e quem o usa precisam estar juntos. Aqui só se liga.
8export const register: Register = on => ligar(on)
9hooks/coleta.ts 1080 lines1// Coleta: os eventos do motor viram o estado do painel. Só a coleta grava; o desenho lê por lerModelo.
2// O motor só segue o $ para funções do próprio arquivo, nunca através de import: por isso tudo que usa o $
3// (ganchos, relógio, ações dos botões, render) mora aqui, ligado por `ligar(on)`; register.tsx só chama ligar.
4// Caminho quente (tool.call, turn.step): nada de disco, nada lento (só a reescrita do rtk chama processo).
5// Arquivos (registro, passos do agy, journal) só no relógio (tique), e só para o que está rodando.
6
7import { atom, read, update } from 'claude-code'
8
9import type { Acao, Acoes, Modelo, Pendente, Plano, Principal, ResumoSessao, Situacao, Trabalhador, Uso, Workflow } from '../types'
10import { desenharPainel } from './desenho'
11import { acaoDe, horaCurta, resumoSessao, traduzirPassoAgy } from './regras'
12import { LIMITE, arquivoDeSaida, caudaDe, envolverBash, envolverPowerShell, fasesDoJornal, lerEventosAgy, type PassosAgy } from './saida'
13import { FRESCA, chaveSessao, ehSessao, separar, sessoesMudaram } from './sessoes'
14
15const trabalhadores = atom({ plugin: 'painel-vivo', key: 'trabalhadores' } as const, {})
16const workflows = atom({ plugin: 'painel-vivo', key: 'workflows' } as const, {})
17const pendentes = atom({ plugin: 'painel-vivo', key: 'pendentes' } as const, {})
18const vistos = atom({ plugin: 'painel-vivo', key: 'vistos' } as const, [])
19const uso = atom({ plugin: 'painel-vivo', key: 'uso' } as const, {})
20const principal = atom({ plugin: 'painel-vivo', key: 'principal' } as const, { ocupado: false, ultimoSinal: 0, tokensNovos: 0, tokensCache: 0, acoes: [] })
21const sessoes = atom({ plugin: 'painel-vivo', key: 'sessoes' } as const, [])
22const relogio = atom({ plugin: 'painel-vivo', key: 'relogio' } as const, 0)
23const aberto = atom({ plugin: 'painel-vivo', key: 'aberto' } as const, '')
24const historicoAberto = atom({ plugin: 'painel-vivo', key: 'historicoAberto' } as const, false)
25const sessoesAbertas = atom({ plugin: 'painel-vivo', key: 'sessoesAbertas' } as const, false)
26const verGlossario = atom({ plugin: 'painel-vivo', key: 'verGlossario' } as const, false)
27const glossario = atom({ plugin: 'painel-vivo', key: 'glossario' } as const, [])
28
29type Ts = Record<string, Trabalhador>
30
31const PLANO = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate'])
32const INTERNAS = new Set(['ToolSearch', 'StructuredOutput', 'SubagentHandback', 'TaskGet', 'TaskList', 'TaskOutput'])
33const COMANDOS = new Set(['Bash', 'PowerShell'])
34const RE_AGY = /python[^;&|]*agy\.py|(^|[;&|(]\s*)agy\s/ // casa `python run_agy.py` e `python agy.py`
35const RE_CODEX = /codex-companion/
36const MESA = 5_000 // comando de agente vira trabalhador a partir daqui
37const MAX_TRAB = 120 // acima disto, os concluídos mais antigos saem do estado
38const PANE = 'painel-vivo'
39
40// Memória do módulo (some numa recarga, sem dano): o que o caminho quente anota e o relógio grava.
41type EmVoo = { id: string; tool: string; agentId?: string; dono: string; inicio: number; rascunho?: Trabalhador; criado: boolean }
42const emVoo = new Map<string, EmVoo>() // chamadas em andamento, por tool_use_id
43const emPedido = new Set<string>() // agentId ('' = principal) com pedido ao modelo em curso: conta como sinal de vida
44const fazendo = new Map<string, string>() // agentId → 'pensando' | 'escrevendo a resposta'
45const gasto = new Map<string, { novos: number; cache: number }>() // tokens ainda não gravados, por agentId
46const modelos = new Map<string, string>() // agentId → modelo visto no turn.step
47const tarefas = new Map<string, Map<string, { status: string; atual?: string }>>() // TaskCreate/TaskUpdate por agente
48const donos = new Map<string, string>() // agentId → id do trabalhador (também gravado em Trabalhador.agentId)
49let turnoPrincipal = ''
50let idSessao = ''
51let nomeSessao = ''
52let arquivoSessao = '' // transcript da sessão (vem em todo evento clássico): é onde o app grava o título da conversa
53let tituloLidoEm = 0
54let tituloDoApp = ''
55
56const corta = (s: unknown, n: number) => String(s ?? '').replace(/\s+/g, ' ').trim().slice(0, n)
57const base = (p: unknown) => String(p ?? '').split(/[\\/]/).pop() ?? ''
58
59// ---------- arquivos e processos (só aqui o $ pode ir) ----------
60
61let pasta = ''
62
63/** Pasta dos registros: `<LOCALAPPDATA>/painel-vivo/runs`, com barras normais (o Git Bash entende). */
64async function pastaRuns($: any): Promise<string> {
65 if (pasta) return pasta
66 const raiz = (await $.env.get('LOCALAPPDATA')) ?? (await $.env.get('TEMP')) ?? 'C:/Temp'
67 pasta = `${String(raiz).replace(/\\/g, '/')}/painel-vivo/runs`
68 return pasta
69}
70
71/** Registros com mais de 1 dia saem: guardam saída de comando (pode haver dado de caso) e o $.fs não apaga. */
72async function limparAntigos($: any): Promise<void> {
73 const dir = (await pastaRuns($)).replace(/'/g, "''")
74 const ps = `if (Test-Path '${dir}') { Get-ChildItem -LiteralPath '${dir}' | Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-1) } | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue }`
75 await $.process.run(['powershell', '-NoProfile', '-Command', ps], { timeoutMs: 20000 }).catch(() => undefined)
76}
77
78/** O hook do rtk só enxergaria o envoltório; então pede a reescrita antes de envolver. */
79async function comRtk($: any, cmd: string): Promise<string> {
80 const r = await $.process.run(['rtk', 'rewrite', cmd], { timeoutMs: 5000 }).catch(() => undefined)
81 const out = String(r?.stdout ?? '').trim()
82 return r?.exitCode === 0 && out.startsWith('rtk') ? out : cmd
83}
84
85const caudas = new Map<string, number>() // registro → mtime da última leitura
86
87/** Cauda e % do registro; undefined se não mudou desde a última leitura. Acima de 3 MB, só o sinal de vida. */
88async function lerCauda($: any, log: string): Promise<{ cauda?: string[]; pct?: number; mtime: number } | undefined> {
89 const st = await $.fs.stat(log).catch(() => undefined)
90 if (!st || st.kind !== 'file' || caudas.get(log) === st.mtimeMs) return undefined
91 caudas.set(log, st.mtimeMs)
92 if (st.size > LIMITE) return { mtime: st.mtimeMs }
93 return { ...caudaDe(String(await $.fs.read(log).catch(() => ''))), mtime: st.mtimeMs }
94}
95
96const agys = new Map<string, PassosAgy & { mtime: number }>() // arquivo de eventos → leitura (relê só se mudou)
97
98/** Passos do agy na pasta do comando: um arquivo por chamada; passos somados, o resto do arquivo mais novo. */
99async function lerAgy($: any, dir: string): Promise<(PassosAgy & { mtime: number }) | undefined> {
100 const itens = await $.fs.list(dir).catch(() => undefined)
101 if (!Array.isArray(itens)) return undefined
102 const arqs = itens.filter((it: any) => it.kind === 'file' && String(it.name).endsWith('.jsonl') && it.size <= LIMITE)
103 .sort((a: any, b: any) => a.mtimeMs - b.mtimeMs)
104 let passos = 0
105 let ultimo: (PassosAgy & { mtime: number }) | undefined
106 for (const it of arqs) {
107 const arq = `${dir}/${it.name}`
108 let l = agys.get(arq)
109 if (!l || l.mtime !== it.mtimeMs) {
110 l = { ...lerEventosAgy(String(await $.fs.read(arq).catch(() => ''))), mtime: it.mtimeMs }
111 agys.set(arq, l)
112 }
113 passos += l.passos
114 ultimo = l
115 }
116 return ultimo && { ...ultimo, passos }
117}
118
119async function lerJornal($: any, dir: string): Promise<Record<string, string>> {
120 return fasesDoJornal(String(await $.fs.read(`${dir}/journal.jsonl`).catch(() => '')))
121}
122
123function trab(id: string, tipo: Trabalhador['tipo'], pai: string, inicio: number, extra: Partial<Trabalhador>): Trabalhador {
124 return { id, tipo, pai, rotulo: '', agora: 'começando', inicio, situacao: 'rodando', ultimoSinal: inicio, acoes: [], ...extra }
125}
126
127/** Dono de uma chamada: 'principal', o subagente (id da chamada Agent) ou o agente de workflow (`ag:<id>`). */
128function donoEm(ts: Ts, agentId?: string): string | undefined {
129 if (!agentId) return 'principal'
130 const d = donos.get(agentId)
131 if (d && ts[d]) return d
132 if (ts[`ag:${agentId}`]) return `ag:${agentId}`
133 return Object.values(ts).find(t => t.agentId === agentId)?.id
134}
135
136// Agente que chega sem agent.spawn (recarga no meio): fica sob o workflow que roda, senão sob o principal.
137function solto(ts: Ts, agentId: string, inicio: number): Trabalhador {
138 const wf = Object.values(ts).filter(t => t.tipo === 'workflow' && t.situacao === 'rodando').pop()
139 return trab(`ag:${agentId}`, 'agente-wf', wf?.id ?? 'principal', inicio, { rotulo: 'agente', agentId })
140}
141
142const comAcao = (t: Trabalhador, a: Acao): Trabalhador =>
143 ({ ...t, acoes: [...t.acoes, a].slice(-60), agora: a.texto, ultimoSinal: Math.max(t.ultimoSinal, a.t) })
144const marcaErro = (acoes: Acao[], a: Acao) => acoes.map(x => (x.t === a.t && x.texto === a.texto ? { ...x, erro: true } : x))
145
146/** Fecha os descendentes que ainda rodam (agente que terminou, workflow que acabou). */
147function fecharFilhos(ts: Ts, raiz: string, fim: number, situacao: Situacao): Ts {
148 const out = { ...ts }
149 const fila = [raiz]
150 const visto = new Set([raiz])
151 while (fila.length) {
152 const p = fila.pop()!
153 for (const t of Object.values(out)) {
154 if (t.pai !== p || visto.has(t.id)) continue
155 visto.add(t.id)
156 fila.push(t.id)
157 if (t.situacao === 'rodando') out[t.id] = { ...t, situacao, fim }
158 }
159 }
160 return out
161}
162
163/** Acima de MAX_TRAB, tira os concluídos mais antigos (nunca quem roda nem os pais de quem roda). */
164function poda(ts: Ts): Ts {
165 const lista = Object.values(ts)
166 if (lista.length <= MAX_TRAB) return ts
167 const fica = new Set(lista.filter(t => t.situacao === 'rodando').map(t => t.id))
168 for (const id of [...fica]) for (let p = ts[id]?.pai; p && ts[p] && !fica.has(p); p = ts[p]!.pai) fica.add(p)
169 const fora = lista.filter(t => !fica.has(t.id)).sort((a, b) => (a.fim ?? a.inicio) - (b.fim ?? b.inicio))
170 const out = { ...ts }
171 for (const t of fora.slice(0, lista.length - MAX_TRAB)) delete out[t.id]
172 return out
173}
174
175function tipoDe(ferramenta: string, e: any): Trabalhador['tipo'] | undefined {
176 if (ferramenta === 'Agent' || ferramenta === 'Task') return 'subagente'
177 if (ferramenta === 'Workflow') return 'workflow'
178 if (ferramenta === 'AskUserQuestion') return 'pergunta'
179 if (!COMANDOS.has(ferramenta)) return undefined
180 const cmd = String(e.command ?? '')
181 return RE_CODEX.test(cmd) ? 'codex' : RE_AGY.test(cmd) ? 'agy' : 'comando'
182}
183
184const campo = (s: string, k: string) =>
185 new RegExp(`\\b${k}\\s*:\\s*(['"\`])((?:\\\\.|(?!\\1)[^\\\\])*)\\1`).exec(s)?.[2]?.replace(/\\(.)/g, '$1')
186
187/** `export const meta = { name, description, phases: [{ title, detail }] }` do script do Workflow. */
188export function lerMeta(script: string): { nome: string; desc: string; fases: Workflow['fases'] } {
189 const i = script.search(/\bmeta\s*=/)
190 const bloco = i < 0 ? '' : script.slice(i, i + 8000)
191 const fasesTxt = /\bphases\s*:\s*\[([\s\S]*?)\]/.exec(bloco)?.[1] ?? ''
192 const fases = [...fasesTxt.matchAll(/\{([^{}]*)\}/g)]
193 .map(m => ({ titulo: campo(m[1]!, 'title') ?? '', ...(campo(m[1]!, 'detail') ? { detalhe: campo(m[1]!, 'detail') } : {}) }))
194 .filter(f => f.titulo)
195 const resto = fasesTxt ? bloco.replace(fasesTxt, '') : bloco
196 return { nome: campo(resto, 'name') ?? '', desc: campo(resto, 'description') ?? '', fases }
197}
198
199function rotuloDe(tipo: Trabalhador['tipo'], e: any): Partial<Trabalhador> {
200 if (tipo === 'subagente') return { rotulo: String(e.subagent_type ?? 'general-purpose'), tarefa: corta(e.description || e.prompt, 300) }
201 if (tipo === 'workflow') {
202 const m = lerMeta(String(e.script ?? ''))
203 return { rotulo: m.nome || String(e.name ?? 'Workflow'), ...(m.desc ? { tarefa: corta(m.desc, 300) } : {}) }
204 }
205 if (tipo === 'pergunta') {
206 const q = e.questions?.[0]
207 return { rotulo: corta(q?.header || 'Pergunta', 40), tarefa: corta(q?.question, 300), agora: 'aguardando resposta' }
208 }
209 return { rotulo: corta(e.description || e.command, 80), tarefa: corta(e.command, 300), agora: 'rodando' }
210}
211
212function pendenteDe(ferramenta: string, e: any, id: string, quem: string, desde: number): Pendente | undefined {
213 if (ferramenta === 'AskUserQuestion') {
214 const q = e.questions?.[0]
215 const opcoes = (q?.options ?? []).map((o: any) => o?.label).filter(Boolean).join(' · ')
216 return { id, tipo: 'pergunta', titulo: `Pergunta: ${corta(q?.question || q?.header || 'aguardando resposta', 90)}`, ...(opcoes ? { detalhe: opcoes } : {}), quem, desde }
217 }
218 if (ferramenta === 'ExitPlanMode') return { id, tipo: 'plano', titulo: 'Plano aguardando aprovação', quem, desde }
219 return undefined
220}
221
222async function tirarPendentes($: any, ids: string[]): Promise<void> {
223 const ps: Record<string, Pendente> = await read($, pendentes)
224 if (!ids.some(i => ps[i])) return
225 await update($, pendentes, p => {
226 const out = { ...p }
227 for (const i of ids) delete out[i]
228 return out
229 })
230}
231
232// ---------- tool.call ----------
233
234type Chamada = { id: string; ferramenta: string; e: any; dono: string; acao: Acao; inicio: number; rascunho?: Trabalhador; criado: boolean }
235
236/**
237 * Toda chamada de ferramenta: vira ação do dono (principal ou agente) e, se importante, trabalhador filho dele.
238 * Bash/PowerShell ganham AGY_EVENTS_DIR (fundo inclusive): o agy chamado por qualquer script grava os passos.
239 */
240async function aoChamar($: any, e: any, next: any) {
241 const ferramenta = String(e.tool)
242 const agentId: string | undefined = e.agentId
243 fazendo.delete(agentId ?? '')
244 if (INTERNAS.has(ferramenta)) return next(e)
245 if (PLANO.has(ferramenta)) {
246 const r = await next(e)
247 await anotarPlano($, e, r).catch(() => undefined)
248 return r
249 }
250 const id = String(e.tool_use_id)
251 const inicio: number = await $.clock.now()
252 const dono = agentId ? donoEm(await read($, trabalhadores), agentId) ?? `ag:${agentId}` : 'principal'
253 const acao: Acao = { t: inicio, ...acaoDe(ferramenta, e) }
254 const tipo = tipoDe(ferramenta, e)
255 const fundo = e.run_in_background === true
256 let entrada = e
257 let rascunho: Trabalhador | undefined
258 if (tipo) {
259 const extra: Partial<Trabalhador> = { ...rotuloDe(tipo, e), ...(fundo ? { emFundo: true } : {}) }
260 // Codex não informa modelo nem tokens na saída; o modelo só se o comando o escolher (--model / -m).
261 const mCodex = tipo === 'codex' ? /(?:--model|\s-m)[\s=]+["']?([\w.:-]+)/.exec(String(e.command ?? ''))?.[1] : undefined
262 if (mCodex) extra.modelo = mCodex
263 if (COMANDOS.has(ferramenta) && String(e.command ?? '').trim()) {
264 const dir = await pastaRuns($)
265 const agyDir = `${dir}/${id}.agy`
266 extra.agyDir = agyDir
267 if (ferramenta === 'PowerShell') entrada = { ...e, command: envolverPowerShell(e.command, agyDir) }
268 else if (fundo) entrada = { ...e, command: envolverBash(e.command, agyDir) } // a saída vai ao arquivo do motor
269 else {
270 extra.log = `${dir}/${id}.log`
271 entrada = { ...e, command: envolverBash(await comRtk($, e.command), agyDir, extra.log) }
272 }
273 }
274 rascunho = trab(id, tipo, dono, inicio, extra)
275 }
276 // Comando de agente em primeiro plano fica só como ação dele; o relógio o promove se passar de 5 s.
277 const criado = !!rascunho && !(agentId && tipo === 'comando' && !fundo)
278 emVoo.set(id, { id, tool: ferramenta, agentId, dono, inicio, rascunho, criado })
279
280 if (agentId || criado) {
281 await update($, trabalhadores, atual => {
282 const out = { ...atual }
283 if (agentId) out[dono] = comAcao(out[dono] ?? solto(atual, agentId, inicio), acao)
284 if (criado) out[id] = rascunho!
285 return poda(out)
286 })
287 }
288 if (!agentId) await update($, principal, p => ({ ...p, ultimoSinal: inicio, acoes: [...p.acoes, acao].slice(-200) }))
289 const pend = pendenteDe(ferramenta, e, id, criado ? id : dono, inicio)
290 if (pend) await update($, pendentes, ps => ({ ...ps, [id]: pend }))
291
292 let r: any
293 try {
294 r = await next(entrada)
295 } finally {
296 const v = emVoo.get(id)
297 emVoo.delete(id)
298 await fechar($, { id, ferramenta, e, dono, acao, inicio, rascunho, criado: v?.criado ?? criado }, r).catch(() => undefined)
299 }
300 return r
301}
302
303async function fechar($: any, c: Chamada, r: any): Promise<void> {
304 const fim: number = await $.clock.now()
305 const res: any = (r && !r.isError && r.deny === undefined ? r.result : undefined) ?? {}
306 const texto = String(r?.text ?? '')
307 let erro = !r || r.deny !== undefined || r.isError === true
308 let fundo = false
309 const muda: Partial<Trabalhador> = {}
310 if (COMANDOS.has(c.ferramenta) && res.backgroundTaskId) {
311 // Em segundo plano (pedido, ctrl+b ou estouro de tempo): fica rodando até o aviso de término.
312 fundo = true
313 muda.emFundo = true
314 muda.tarefaFundo = String(res.backgroundTaskId)
315 const saida = c.rascunho?.log ? undefined : arquivoDeSaida(texto)
316 if (saida) muda.log = saida
317 }
318 if (c.ferramenta === 'Agent' || c.ferramenta === 'Task') {
319 if (res.agentId) muda.agentId = String(res.agentId)
320 if (res.resolvedModel) muda.modelo = String(res.resolvedModel)
321 if (res.status === 'async_launched') {
322 fundo = true
323 muda.emFundo = true
324 muda.tarefaFundo = String(res.agentId ?? '')
325 }
326 }
327 if (c.ferramenta === 'Workflow') {
328 erro = erro || !!res.error
329 const tarefa = res.taskId ?? /Task ID: ([\w-]+)/.exec(texto)?.[1] ?? /running in background with ID: ([\w-]+)/.exec(texto)?.[1]
330 if (!erro && tarefa) {
331 fundo = true
332 muda.emFundo = true
333 muda.tarefaFundo = String(tarefa)
334 }
335 const w = await workflowDe($, c, res, texto)
336 muda.rotulo = w.nome
337 if (w.desc) muda.tarefa = corta(w.desc, 300)
338 await update($, workflows, ws => ({ ...ws, [c.id]: { ...w, fasePorAgente: ws[c.id]?.fasePorAgente ?? {} } }))
339 }
340 const promover = !c.criado && !!c.rascunho && (fim - c.inicio >= MESA || fundo)
341 if (c.criado || promover || (erro && c.dono !== 'principal')) {
342 await update($, trabalhadores, atual => {
343 const out = { ...atual }
344 if (promover && !out[c.id]) out[c.id] = c.rascunho!
345 const t = out[c.id]
346 if (t && (c.criado || promover)) {
347 const fecha = !fundo && t.situacao === 'rodando'
348 out[c.id] = { ...t, ...muda, ...(fecha ? { situacao: erro ? 'falhou' : 'feito', fim, acoes: t.acoes.slice(-20) } : {}) }
349 }
350 const d = out[c.dono]
351 if (erro && d) out[c.dono] = { ...d, acoes: marcaErro(d.acoes, c.acao) }
352 return out
353 })
354 }
355 if (erro && c.dono === 'principal') await update($, principal, p => ({ ...p, acoes: marcaErro(p.acoes, c.acao) }))
356 await tirarPendentes($, [c.id, `perm:${c.e.agentId ?? 'principal'}:${c.ferramenta}`])
357}
358
359async function workflowDe($: any, c: Chamada, res: any, texto: string): Promise<Workflow> {
360 let script = String(c.e.script ?? '')
361 const caminho = res.scriptPath ?? c.e.scriptPath
362 if (!script && caminho) script = String(await $.fs.read(String(caminho)).catch(() => ''))
363 const meta = lerMeta(script)
364 const dir = res.transcriptDir ?? /Transcript dir: ([^\n]+)/.exec(texto)?.[1]?.trim()
365 return {
366 id: c.id,
367 nome: res.workflowName || meta.nome || String(c.e.name ?? 'Workflow'),
368 desc: meta.desc,
369 fases: meta.fases,
370 fasePorAgente: {},
371 ...(dir ? { dir: String(dir).replace(/\\/g, '/') } : {}),
372 ...(res.runId ? { runId: String(res.runId) } : {}),
373 }
374}
375
376/** Checklist declarado (TodoWrite, TaskCreate/TaskUpdate) vira o plano do agente que o declarou. */
377async function anotarPlano($: any, e: any, r: any): Promise<void> {
378 const chave = e.agentId ?? ''
379 let plano: Plano
380 if (e.tool === 'TodoWrite') {
381 const todos: any[] = Array.isArray(e.todos) ? e.todos : []
382 const atual = todos.find(t => t?.status === 'in_progress')?.activeForm
383 plano = { feitos: todos.filter(t => t?.status === 'completed').length, total: todos.length, ...(atual ? { atual } : {}) }
384 } else {
385 const lista = tarefas.get(chave) ?? new Map<string, { status: string; atual?: string }>()
386 tarefas.set(chave, lista)
387 const tid = String(e.tool === 'TaskCreate' ? r?.result?.task?.id ?? '' : e.taskId ?? '')
388 if (!tid) return
389 const x = lista.get(tid)
390 if (e.status === 'deleted') lista.delete(tid)
391 else lista.set(tid, { status: e.status ?? x?.status ?? 'pending', atual: e.activeForm ?? e.subject ?? x?.atual })
392 const vs = [...lista.values()]
393 const atual = vs.find(v => v.status === 'in_progress')?.atual
394 plano = { feitos: vs.filter(v => v.status === 'completed').length, total: vs.length, ...(atual ? { atual } : {}) }
395 }
396 if (!chave) await update($, principal, p => ({ ...p, plano }))
397 else {
398 await update($, trabalhadores, ts => {
399 const d = donoEm(ts, chave)
400 return d && ts[d] ? { ...ts, [d]: { ...ts[d]!, plano } } : ts
401 })
402 }
403}
404
405// ---------- agentes e turnos ----------
406
407/**
408 * agent.spawn dispara para o subagente da chamada Agent e para cada agent() de Workflow (e.workflow presente,
409 * e.tool_use_id = a chamada Workflow). Agente de workflow vira `ag:<agentId>` sob o workflow.
410 */
411async function aoNascerAgente($: any, e: any, next: any) {
412 const r = await next(e)
413 const agentId: string | undefined = r?.agentId
414 if (!agentId) return r
415 const inicio: number = await $.clock.now()
416 const ws: Record<string, Workflow> = e.workflow ? await read($, workflows) : {}
417 await update($, trabalhadores, ts => {
418 const out = { ...ts }
419 if (e.workflow) {
420 const porRun = Object.values(ws).find(w => w.runId && w.runId === e.workflow.runId)?.id
421 const pai = e.tool_use_id || porRun || donoEm(ts, e.parentAgentId) || 'principal'
422 const id = `ag:${agentId}`
423 const fase = ws[pai]?.fasePorAgente[agentId]
424 out[id] = {
425 ...trab(id, 'agente-wf', pai, inicio, { agentId, modelo: r.model, ...(fase ? { fase } : {}) }),
426 ...(out[id] ?? {}), // chegou antes uma ferramenta dele (solto): mantém ações e sinais
427 pai,
428 rotulo: String(e.description || e.name || 'agente'),
429 tarefa: corta(e.prompt, 300),
430 }
431 donos.set(agentId, id)
432 } else {
433 const id = e.tool_use_id && ts[e.tool_use_id] ? e.tool_use_id : `ag:${agentId}`
434 const t = out[id] ?? trab(id, 'subagente', donoEm(ts, e.parentAgentId) ?? 'principal', inicio, { tarefa: corta(e.description || e.prompt, 300) })
435 out[id] = { ...t, agentId, modelo: r.model, rotulo: String(e.subagentType || t.rotulo || 'general-purpose'), ...(e.background ? { emFundo: true } : {}) }
436 donos.set(agentId, id)
437 }
438 return poda(out)
439 })
440 return r
441}
442
443async function aoComecarTurno($: any, e: any): Promise<void> {
444 turnoPrincipal = String(e.turnId ?? '')
445 const agora: number = await $.clock.now()
446 await update($, principal, p => ({ ...p, ocupado: true, ultimoSinal: agora }))
447}
448
449const novosDe = (u: any) => (u?.input_tokens ?? 0) + (u?.output_tokens ?? 0) + (u?.cache_creation_input_tokens ?? 0)
450
451/** Fim de turno: principal fica ocioso; agente fecha (e os filhos que ainda rodam), com tokens conferidos. */
452async function aoTerminarTurno($: any, e: any): Promise<void> {
453 const agora: number = await $.clock.now()
454 if (!e.agentId) {
455 await update($, principal, p => ({ ...p, ocupado: false, fimTurno: agora }))
456 if (e.reason === 'answer') {
457 const ps: Record<string, Pendente> = await read($, pendentes)
458 await tirarPendentes($, Object.values(ps).filter(p => p.tipo === 'falha-api').map(p => p.id))
459 }
460 return
461 }
462 const g = gasto.get(e.agentId)
463 gasto.delete(e.agentId)
464 fazendo.delete(e.agentId)
465 const situacao: Situacao = e.reason === 'answer' ? 'feito' : e.reason === 'aborted' ? 'parado' : 'falhou'
466 await update($, trabalhadores, ts => {
467 const id = donoEm(ts, e.agentId)
468 const t = id ? ts[id] : undefined
469 if (!t) return ts
470 let novos = (t.tokensNovos ?? 0) + (g?.novos ?? 0)
471 let cache = (t.tokensCache ?? 0) + (g?.cache ?? 0)
472 if (e.usage) {
473 novos = Math.max(novos, novosDe(e.usage))
474 cache = Math.max(cache, e.usage.cache_read_input_tokens ?? 0)
475 }
476 const fecha = t.situacao === 'rodando'
477 const out = { ...ts, [t.id]: { ...t, tokensNovos: novos, tokensCache: cache, ...(fecha ? { situacao, fim: agora, acoes: t.acoes.slice(-20) } : {}) } }
478 return fecha ? fecharFilhos(out, t.id, agora, 'feito') : out
479 })
480 if (e.reason === 'answer') await tirarPendentes($, [`api:${e.agentId}`])
481}
482
483// turn.step: cada trecho só anota na memória do módulo; o relógio grava no máximo a cada 2 s.
484function comecarPedido(agentId?: string, modelo?: string): void {
485 emPedido.add(agentId ?? '')
486 if (agentId && modelo) modelos.set(agentId, modelo)
487}
488
489function trecho(agentId: string | undefined, c: any): void {
490 const k = agentId ?? ''
491 if (c.kind === 'text') fazendo.set(k, 'escrevendo a resposta')
492 else if (c.kind === 'thinking' || c.kind === 'tool') fazendo.set(k, 'pensando')
493 else if (c.kind === 'stop' && c.usage) {
494 const g = gasto.get(k) ?? { novos: 0, cache: 0 }
495 g.novos += novosDe(c.usage)
496 g.cache += c.usage.cache_read_input_tokens ?? 0
497 gasto.set(k, g)
498 }
499}
500
501function fimPedido(agentId?: string): void {
502 emPedido.delete(agentId ?? '')
503}
504
505// ---------- fim de tarefa em segundo plano ----------
506
507const tag = (s: string, n: string) => new RegExp(`<${n}>([^<]+)</${n}>`).exec(s)?.[1]?.trim()
508
509function situacaoDe(status = '', codigo?: string): Situacao {
510 if (/killed|stopped/.test(status)) return 'parado'
511 if (/fail|error/.test(status) || (codigo !== undefined && codigo !== '0')) return 'falhou'
512 return 'feito'
513}
514
515/** Aviso de término (prompt.submit com origin.kind 'task-notification'; reserva: o XML no texto). */
516async function aoReceberPrompt($: any, e: any): Promise<void> {
517 const texto = String(e.text ?? '')
518 if (e.origin?.kind !== 'task-notification' && !texto.includes('<task-notification>')) return
519 const avisos = [...texto.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)].map(m => m[1] ?? '')
520 if (!avisos.length) return
521 const fim: number = await $.clock.now()
522 const fechar = avisos.map(corpo => ({
523 ids: [tag(corpo, 'tool-use-id'), tag(corpo, 'task-id')].filter((x): x is string => !!x),
524 situacao: situacaoDe(tag(corpo, 'status'), /exit code (\d+)/.exec(corpo)?.[1]),
525 }))
526 await update($, trabalhadores, ts => {
527 let out = { ...ts }
528 for (const f of fechar) {
529 const t = Object.values(out).find(x => f.ids.includes(x.id) || (!!x.tarefaFundo && f.ids.includes(x.tarefaFundo)))
530 if (!t || t.situacao !== 'rodando') continue
531 out[t.id] = { ...t, situacao: f.situacao, fim }
532 out = fecharFilhos(out, t.id, fim, f.situacao === 'feito' ? 'feito' : 'parado')
533 }
534 return out
535 })
536}
537
538/** classic.Stop: lista vazia de tarefas em segundo plano prova que nada mais roda sozinho (aviso perdido). */
539async function aoParar($: any, e: any): Promise<void> {
540 if (!Array.isArray(e.background_tasks) || e.background_tasks.length) return
541 const ts: Ts = await read($, trabalhadores)
542 if (!Object.values(ts).some(t => t.emFundo && t.situacao === 'rodando')) return
543 const fim: number = await $.clock.now()
544 await update($, trabalhadores, atual => {
545 let out = { ...atual }
546 for (const t of Object.values(atual)) {
547 if (!t.emFundo || out[t.id]?.situacao !== 'rodando') continue
548 out[t.id] = { ...t, situacao: 'feito', fim }
549 out = fecharFilhos(out, t.id, fim, 'feito')
550 }
551 return out
552 })
553}
554
555// ---------- pendências: permissão, falha de API ----------
556
557function tituloPermissao(tool: string, inp: any, cmd?: string): string {
558 const desc = corta(inp?.description, 80)
559 if (COMANDOS.has(tool)) return `Permissão para rodar: ${desc ? desc[0]!.toLowerCase() + desc.slice(1) : corta(cmd ?? inp?.command, 60)}`
560 if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(tool)) return `Permissão para editar ${base(inp?.file_path ?? inp?.notebook_path) || 'um arquivo'}`
561 if (tool === 'WebFetch') {
562 let host = corta(inp?.url, 40)
563 try { host = new URL(String(inp?.url)).hostname } catch {}
564 return `Permissão para acessar ${host}`
565 }
566 return `Permissão para usar ${tool.startsWith('mcp__') ? tool.split('__').pop() : tool}`
567}
568
569/**
570 * classic.PermissionRequest não traz tool_use_id: a chamada é a mais recente em andamento da mesma ferramenta
571 * no mesmo agente. Sem ela, `perm:<agente>:<ferramenta>`. Devolve o id da pendência. Só observa.
572 */
573async function anotarPermissao($: any, e: any): Promise<string> {
574 const agentId: string | undefined = e.agent_id
575 const v = [...emVoo.values()].filter(x => x.tool === e.tool_name && (x.agentId ?? '') === (agentId ?? '')).pop()
576 const id = v?.id ?? `perm:${agentId ?? 'principal'}:${e.tool_name}`
577 const quem = v ? (v.criado ? v.id : v.dono) : agentId ? donoEm(await read($, trabalhadores), agentId) ?? 'principal' : 'principal'
578 const cmd = v?.rascunho?.tarefa
579 const detalhe = corta(cmd ?? e.tool_input?.file_path ?? e.tool_input?.url ?? '', 200)
580 const desde: number = await $.clock.now()
581 const p: Pendente = { id, tipo: 'permissao', titulo: tituloPermissao(String(e.tool_name), e.tool_input, cmd), ...(detalhe ? { detalhe } : {}), quem, desde }
582 await update($, pendentes, ps => ({ ...ps, [id]: p }))
583 return id
584}
585
586async function negarPermissao($: any, e: any): Promise<void> {
587 await tirarPendentes($, [String(e.tool_use_id), `perm:${e.agent_id ?? 'principal'}:${e.tool_name}`])
588}
589
590const FALHAS: Record<string, string> = {
591 rate_limit: 'Limite de uso atingido',
592 overloaded: 'Serviço sobrecarregado',
593 billing_error: 'Problema de cobrança na conta',
594 authentication_failed: 'Falha de autenticação',
595 server_error: 'Erro no servidor da API',
596 max_output_tokens: 'Resposta cortada no limite de tamanho',
597}
598
599/** classic.StopFailure: some no próximo turno bem-sucedido (ou ao dispensar). */
600async function anotarFalhaApi($: any, e: any): Promise<void> {
601 const agentId: string | undefined = e.agent_id
602 const id = `api:${agentId ?? 'principal'}`
603 const quem = agentId ? donoEm(await read($, trabalhadores), agentId) ?? 'principal' : 'principal'
604 const desde: number = await $.clock.now()
605 const detalhe = corta(e.error_details, 200)
606 const p: Pendente = { id, tipo: 'falha-api', titulo: FALHAS[String(e.error)] ?? `Falha da API (${e.error})`, ...(detalhe ? { detalhe } : {}), quem, desde }
607 await update($, pendentes, ps => ({ ...ps, [id]: p }))
608}
609
610// ---------- uso, título, sessão ----------
611
612async function anotarUso($: any, ctx: any, rl: any[]): Promise<void> {
613 const k = (n: string) => (rl ?? []).find(r => r?.kind === n)
614 const novo: Uso = JSON.parse(JSON.stringify({
615 contextoPct: ctx?.percent,
616 cota5h: k('five_hour')?.percentUsed,
617 renova5h: k('five_hour')?.resetsAt,
618 semana: k('seven_day')?.percentUsed,
619 renovaSemana: k('seven_day')?.resetsAt,
620 }))
621 const antes: Uso = await read($, uso)
622 const junto = { ...antes, ...novo } // cota que não veio nesta leitura fica a anterior
623 if (JSON.stringify(junto) !== JSON.stringify(antes)) await update($, uso, () => junto)
624}
625
626// O app grava o nome da conversa no transcript ({"type":"custom-title","customTitle":"…"}); session_title não chega.
627// O transcript passa de 4 MiB (limite do $.fs.read), então um Python curto devolve só o título, em JSON ASCII.
628const PY_TITULO = `import sys,json
629t=""
630for l in open(sys.argv[1],encoding="utf-8",errors="ignore"):
631 if '"custom-title"' in l:
632 try: t=json.loads(l).get("customTitle") or t
633 except Exception: pass
634print(json.dumps(t))`
635
636/** Lê o título da conversa a cada 30 s até achar; depois a cada 5 min (pega renomeação). */
637async function lerTituloDoApp($: any, agora: number): Promise<void> {
638 if (!arquivoSessao || agora - tituloLidoEm < (tituloDoApp ? 300_000 : 30_000)) return
639 tituloLidoEm = agora
640 const r = await $.process.run(['python', '-I', '-c', PY_TITULO, arquivoSessao], { timeoutMs: 15000 }).catch(() => undefined)
641 let t = ''
642 try { t = String(JSON.parse(String(r?.stdout ?? '').trim() || '""') ?? '') } catch {}
643 if (!t) return
644 tituloDoApp = t
645 await anotarTitulo($, t)
646}
647
648/** Guarda o caminho do transcript que todo evento clássico traz. */
649function anotarArquivo(e: any): void {
650 if (e?.transcript_path) arquivoSessao = String(e.transcript_path)
651}
652
653/** Título da sessão (o mesmo da lista do app): do transcript, ou de session_title se um dia vier. */
654async function anotarTitulo($: any, titulo: unknown): Promise<void> {
655 const t = corta(titulo, 80)
656 if (!t) return
657 nomeSessao = t
658 const p: Principal = await read($, principal)
659 if (p.titulo !== t) await update($, principal, x => ({ ...x, titulo: t }))
660}
661
662async function iniciar($: any): Promise<void> {
663 idSessao = await $.session.id()
664 const salvo = await $.store.get('glossario')
665 if (Array.isArray(salvo)) await update($, glossario, () => salvo)
666 const u = await $.session.usage().catch(() => undefined)
667 if (u) await anotarUso($, u.context, u.rateLimits)
668 // Nome de reserva até o app dar o título: pasta e hora de início (distingue sessões na mesma pasta).
669 if (!nomeSessao) nomeSessao = `${base(await $.session.cwd())} · ${horaCurta(u?.startedAt ?? (await $.clock.now()))}`
670 void limparAntigos($)
671}
672
673/** Ao encerrar: some da lista das outras sessões na hora. */
674async function encerrar($: any, id?: string): Promise<void> {
675 const qual = id || idSessao
676 if (qual) await $.store.delete(chaveSessao(qual)).catch(() => undefined)
677}
678
679async function textoGlossario($: any): Promise<string> {
680 const g: { termo: string; definicao: string }[] = await read($, glossario)
681 return g.length ? g.map(t => `- **${t.termo}**: ${t.definicao}`).join('\n') : 'O glossário está vazio. Use Explicar no painel para começar.'
682}
683
684// ---------- relógio ----------
685
686let girando = false
687let ultimoUso = 0
688let ultimaSessao = 0
689let publicouOcioso = false
690
691const difere = (t: Trabalhador, m: Partial<Trabalhador>) =>
692 Object.entries(m).some(([k, v]) => (k === 'ultimoSinal' ? (v as number) > t.ultimoSinal : JSON.stringify((t as any)[k]) !== JSON.stringify(v)))
693
694/** A cada 2 s: só age se há algo rodando ou pendente. Ocioso: só lê as outras sessões, a cada 10 s. */
695async function tique($: any): Promise<void> {
696 if (girando) return
697 girando = true
698 try {
699 await girar($)
700 } catch {
701 // um tique perdido não derruba o relógio
702 } finally {
703 girando = false
704 }
705}
706
707async function girar($: any): Promise<void> {
708 const agora: number = await $.clock.now()
709 await lerTituloDoApp($, agora).catch(nada)
710 const [ts, pri, pend] = (await Promise.all([read($, trabalhadores), read($, principal), read($, pendentes)])) as [Ts, Principal, Record<string, Pendente>]
711 const ativo = pri.ocupado || emVoo.size > 0 || Object.keys(pend).length > 0 || Object.values(ts).some(t => t.situacao === 'rodando')
712 if (!ativo) {
713 if (agora - ultimaSessao >= 10_000) {
714 ultimaSessao = agora
715 await trocarSessoes($, agora, !publicouOcioso) // publica "ocioso" uma vez; depois as outras deixam de nos ver
716 publicouOcioso = true
717 }
718 return
719 }
720 publicouOcioso = false
721 const muda: Record<string, Partial<Trabalhador>> = {}
722 const mexe = (id: string | undefined, m: Partial<Trabalhador>) => {
723 if (id && id !== 'principal') muda[id] = { ...muda[id], ...m }
724 }
725
726 // 1. comando de agente em voo vira trabalhador aos 5 s, ou já, se o agy começou a gravar passos
727 const novos: Trabalhador[] = []
728 for (const v of emVoo.values()) {
729 if (v.criado || !v.rascunho) continue
730 if (agora - v.inicio >= MESA || (v.rascunho.agyDir && (await $.fs.exists(v.rascunho.agyDir).catch(() => false)))) {
731 v.criado = true
732 novos.push(v.rascunho)
733 }
734 }
735 const vivo: Ts = { ...ts }
736 for (const n of novos) vivo[n.id] = vivo[n.id] ?? n
737
738 // 2. sinais do modelo: pedido em curso conta como vida; o que faz; modelo; tokens
739 for (const ag of emPedido) if (ag) mexe(donoEm(vivo, ag), { ultimoSinal: agora })
740 for (const [ag, txt] of fazendo) {
741 const id = donoEm(vivo, ag)
742 if (ag && id && vivo[id]?.situacao === 'rodando') mexe(id, { agora: txt })
743 }
744 for (const [ag, mod] of modelos) {
745 const id = donoEm(vivo, ag)
746 if (id && vivo[id] && !vivo[id]!.modelo) mexe(id, { modelo: mod })
747 }
748 modelos.clear()
749 const tok = new Map(gasto)
750 gasto.clear()
751
752 // 3. saída dos comandos (cauda, %) e passos do agy, só de quem roda
753 for (const t of Object.values(vivo)) {
754 if (t.situacao !== 'rodando') continue
755 if (t.log) {
756 const c = await lerCauda($, t.log)
757 if (c) {
758 mexe(t.id, { ...(c.cauda ? { cauda: c.cauda } : {}), ...(c.pct !== undefined ? { pct: c.pct } : {}), ultimoSinal: c.mtime })
759 const linha = t.tipo === 'codex' ? c.cauda?.filter(l => l.startsWith('[codex]')).pop() : undefined
760 if (linha) mexe(t.id, { agora: corta(linha.replace('[codex]', ''), 80) })
761 }
762 }
763 if (t.agyDir) {
764 const g = await lerAgy($, t.agyDir)
765 if (g) mexe(t.id, { tipo: 'agy', passos: g.passos, agora: traduzirPassoAgy(g.agora), ...(g.modelo ? { modelo: g.modelo } : {}), ...(g.tokens ? { tokensNovos: g.tokens } : {}), ultimoSinal: g.mtime })
766 }
767 }
768
769 // 4. fase de cada agente de workflow (journal), só enquanto o workflow roda
770 const wfs: Record<string, Workflow> = await read($, workflows)
771 const fasesNovas: Record<string, Record<string, string>> = {}
772 for (const t of Object.values(vivo)) {
773 const w = t.tipo === 'workflow' && t.situacao === 'rodando' ? wfs[t.id] : undefined
774 if (!w?.dir) continue
775 const fases = await lerJornal($, w.dir)
776 if (JSON.stringify(fases) !== JSON.stringify(w.fasePorAgente)) fasesNovas[t.id] = fases
777 for (const [ag, f] of Object.entries(fases)) if (vivo[`ag:${ag}`]) mexe(`ag:${ag}`, { fase: f })
778 }
779 if (Object.keys(fasesNovas).length) {
780 await update($, workflows, ws => {
781 const out = { ...ws }
782 for (const [id, f] of Object.entries(fasesNovas)) if (out[id]) out[id] = { ...out[id]!, fasePorAgente: f }
783 return out
784 })
785 }
786
787 // 5. grava tudo de uma vez, só se algo mudou
788 const agentesTok = [...tok.keys()].filter(Boolean)
789 if (novos.length || agentesTok.length || Object.entries(muda).some(([id, m]) => vivo[id] && difere(vivo[id]!, m))) {
790 await update($, trabalhadores, atual => {
791 const out = { ...atual }
792 for (const n of novos) out[n.id] = out[n.id] ?? n
793 for (const [id, m] of Object.entries(muda)) {
794 const t = out[id]
795 if (t) out[id] = { ...t, ...m, ultimoSinal: Math.max(t.ultimoSinal, m.ultimoSinal ?? 0) }
796 }
797 for (const ag of agentesTok) {
798 const id = donoEm(out, ag)
799 const t = id ? out[id] : undefined
800 const g = tok.get(ag)!
801 if (t) out[t.id] = { ...t, tokensNovos: (t.tokensNovos ?? 0) + g.novos, tokensCache: (t.tokensCache ?? 0) + g.cache }
802 }
803 return poda(out)
804 })
805 }
806 const gp = tok.get('')
807 if (emPedido.has('') || gp) {
808 await update($, principal, p => ({
809 ...p,
810 ...(emPedido.has('') ? { ultimoSinal: agora } : {}),
811 ...(gp ? { tokensNovos: p.tokensNovos + gp.novos, tokensCache: p.tokensCache + gp.cache } : {}),
812 }))
813 }
814
815 // 6. uso (a cada 10 s) e outras sessões (a cada 5 s)
816 if (agora - ultimoUso >= 10_000) {
817 ultimoUso = agora
818 const u = await $.session.usage().catch(() => undefined)
819 if (u) await anotarUso($, u.context, u.rateLimits)
820 }
821 if (agora - ultimaSessao >= 5_000) {
822 ultimaSessao = agora
823 await trocarSessoes($, agora, true)
824 }
825
826 // 7. o relógio do painel: redesenha (barras até "agora")
827 await update($, relogio, () => agora)
828}
829
830/** Publica o resumo desta sessão no $.store (se `publica`) e traz o das outras para o estado. */
831async function trocarSessoes($: any, agora: number, publica: boolean): Promise<void> {
832 if (!idSessao) idSessao = await $.session.id()
833 const minha = chaveSessao(idSessao)
834 if (publica) {
835 const m = await lerModelo($)
836 await $.store.set(minha, resumoSessao(m, idSessao, m.principal.titulo || nomeSessao || 'Sessão')) // título do app (sobrevive à recarga) antes do nome de reserva
837 }
838 const entradas: [string, unknown][] = []
839 for (const k of (await $.store.keys()) as string[]) if (ehSessao(k) && k !== minha) entradas.push([k, await $.store.get(k)])
840 const { outras, apagar } = separar(entradas, agora)
841 for (const k of apagar) await $.store.delete(k).catch(() => undefined)
842 if (sessoesMudaram(outras, await read($, sessoes), agora)) await update($, sessoes, () => outras)
843}
844
845// ---------- ações dos botões ----------
846
847async function abrir($: any, id: string): Promise<void> {
848 await update($, aberto, v => (v === id ? '' : id))
849}
850
851async function dispensar($: any, ids: string[]): Promise<void> {
852 await update($, vistos, v => [...new Set([...v, ...ids])].slice(-300))
853 const ps: Record<string, Pendente> = await read($, pendentes)
854 const apagaveis = ids.filter(i => ps[i]?.tipo === 'falha-api')
855 if (apagaveis.length) await tirarPendentes($, apagaveis)
856}
857
858const SISTEMA = `Você explica o que um trabalho do computador fez (comando, agente ou workflow) para uma pessoa leiga em programação.
859Português do Brasil, com precisão técnica, tom adulto e direto. Defina cada termo técnico em poucas palavras na primeira vez que aparecer. No máximo 120 palavras.
860Estrutura em markdown: **O que faz**, **O que a saída diz**, e **Por que deu erro** só se houve erro.
861Responda APENAS um JSON válido, sem cercas de código: {"explicacao": "<markdown>", "termos": [{"termo": "<palavra>", "definicao": "<uma frase>"}]}, com até 4 termos técnicos que apareceram.`
862
863async function mudar($: any, id: string, m: Partial<Trabalhador>): Promise<void> {
864 await update($, trabalhadores, ts => (ts[id] ? { ...ts, [id]: { ...ts[id]!, ...m } } : ts))
865}
866
867/** Haiku explica o trabalhador; os termos novos vão para o glossário (só os termos ficam em disco). */
868async function explicar($: any, id: string): Promise<void> {
869 const t: Trabalhador | undefined = ((await read($, trabalhadores)) as Ts)[id]
870 if (!t || t.explicando) return
871 await mudar($, id, { explicando: true })
872 let explicacao = 'Não consegui explicar agora.'
873 let termos: { termo: string; definicao: string }[] = []
874 try {
875 const prompt = [
876 `Tipo: ${t.tipo}`,
877 `Nome técnico: ${t.rotulo}`,
878 `O que foi pedido: ${t.tarefa || '(não informado)'}`,
879 `Situação: ${t.situacao === 'rodando' ? 'ainda rodando' : t.situacao === 'falhou' ? 'terminou com ERRO' : t.situacao === 'parado' ? 'foi interrompido' : 'terminou sem erro'}`,
880 `Últimas ações: ${t.acoes.slice(-8).map(a => a.texto).join('; ') || '(nenhuma)'}`,
881 `Últimas linhas da saída:\n${(t.cauda ?? []).join('\n') || '(sem saída)'}`,
882 ].join('\n')
883 const r = await $.model.complete({ model: 'haiku', system: SISTEMA, prompt, maxTokens: 900 })
884 if (!r.isAnswered) explicacao = `Não consegui explicar (${r.reason}).`
885 else {
886 try {
887 const j = JSON.parse(String(r.text).replace(/^```(?:json)?\s*|\s*```$/g, ''))
888 explicacao = String(j.explicacao ?? explicacao)
889 termos = Array.isArray(j.termos) ? j.termos.filter((x: any) => x?.termo && x?.definicao) : []
890 } catch {
891 explicacao = String(r.text)
892 }
893 }
894 } catch (err: any) {
895 // O motivo aparece no cartão: sem ele, "não funciona" fica sem pista.
896 explicacao = `Não consegui explicar: ${String(err?.message ?? err).slice(0, 160)}`
897 } finally {
898 await mudar($, id, { explicando: false, explicacao })
899 }
900 if (!termos.length) return
901 await update($, glossario, g => {
902 const ja = new Set(g.map(x => x.termo.toLowerCase()))
903 return [...g, ...termos.filter(x => !ja.has(String(x.termo).toLowerCase())).map(x => ({ termo: String(x.termo), definicao: String(x.definicao) }))]
904 })
905 await $.store.set('glossario', await read($, glossario))
906}
907
908/**
909 * Para o trabalhador: tarefa em segundo plano (comando, workflow, subagente) por TaskStop; chamada em primeiro
910 * plano do principal por $.turn.abort. Dentro de agente em primeiro plano não há como parar só ela: não faz nada.
911 */
912async function parar($: any, id: string): Promise<void> {
913 const t: Trabalhador | undefined = ((await read($, trabalhadores)) as Ts)[id]
914 if (!t || t.situacao !== 'rodando') return
915 try {
916 if (t.tarefaFundo) await $.tool.call({ tool: 'TaskStop', task_id: t.tarefaFundo })
917 else if (t.pai === 'principal' && turnoPrincipal) await $.turn.abort({ turnId: turnoPrincipal })
918 else return
919 } catch {
920 return
921 }
922 const fim: number = await $.clock.now()
923 await update($, trabalhadores, atual =>
924 atual[id]?.situacao === 'rodando' ? fecharFilhos({ ...atual, [id]: { ...atual[id]!, situacao: 'parado', fim } }, id, fim, 'parado') : atual)
925}
926
927async function alternar($: any, chave: 'historicoAberto' | 'sessoesAbertas' | 'verGlossario'): Promise<void> {
928 if (chave === 'historicoAberto') await update($, historicoAberto, v => !v)
929 else if (chave === 'sessoesAbertas') await update($, sessoesAbertas, v => !v)
930 else await update($, verGlossario, v => !v)
931}
932
933/** Todo o estado num objeto só; lido pelo render, assina o redesenho (inclusive o relógio). */
934async function lerModelo($: any): Promise<Modelo> {
935 const [, agora, ts, wf, pe, vi, us, pr, ss, ab, ha, sa, vg, gl] = await Promise.all([
936 read($, relogio),
937 $.clock.now() as Promise<number>,
938 read($, trabalhadores),
939 read($, workflows),
940 read($, pendentes),
941 read($, vistos),
942 read($, uso),
943 read($, principal),
944 read($, sessoes),
945 read($, aberto),
946 read($, historicoAberto),
947 read($, sessoesAbertas),
948 read($, verGlossario),
949 read($, glossario),
950 ])
951 return {
952 agora,
953 trabalhadores: ts,
954 workflows: wf,
955 pendentes: pe,
956 vistos: vi,
957 uso: us,
958 principal: pr,
959 sessoes: (ss as ResumoSessao[]).filter(s => agora - s.t <= FRESCA),
960 aberto: ab,
961 historicoAberto: ha,
962 sessoesAbertas: sa,
963 verGlossario: vg,
964 glossario: gl,
965 }
966}
967
968// ---------- ligação: todos os ganchos do mod (register.tsx só chama ligar) ----------
969
970let ligado = false
971const nada = () => undefined
972
973/** Relógio e comandos, uma vez por carga do módulo: a recarga quente derruba o relógio e não repete session.start. */
974async function garantir($: any): Promise<void> {
975 if (ligado) return
976 ligado = true
977 $.clock.every(2000, () => void tique($))
978 await $.command.register({ name: 'painel', description: 'Abre o painel com o que está rodando agora' })
979 await $.command.register({ name: 'glossario', description: 'Mostra o glossário de termos explicados no painel' })
980}
981
982export function ligar(on: any): void {
983 on('session.start', async ($: any, e: any, next: any) => {
984 const r = await next(e)
985 await iniciar($).catch(nada)
986 await garantir($).catch(nada)
987 void $.ui.open({ id: PANE, title: 'Painel' })
988 return r
989 })
990 on('session.end', async ($: any, e: any, next: any) => {
991 await encerrar($, e.sessionId).catch(nada)
992 return next(e)
993 })
994 on('command.run', { command: 'painel' }, async ($: any) => {
995 await $.ui.open({ id: PANE, title: 'Painel' })
996 return { text: 'Painel aberto.' }
997 })
998 on('command.run', { command: 'glossario' }, async ($: any) => ({ text: await textoGlossario($) }))
999
1000 on('session.measure', async ($: any, e: any, next: any) => {
1001 await anotarUso($, e.context, e.rateLimits).catch(nada)
1002 return next(e)
1003 })
1004 on('turn.start', async ($: any, e: any, next: any) => {
1005 await garantir($).catch(nada)
1006 await aoComecarTurno($, e).catch(nada)
1007 return next(e)
1008 })
1009 // Só observa: cada trecho vai à memória do módulo e o fluxo segue intacto.
1010 on('turn.step', async function* ($: any, e: any, next: any) {
1011 comecarPedido(e.agentId, e.model)
1012 try {
1013 for await (const c of next(e)) {
1014 try { trecho(e.agentId, c) } catch {}
1015 yield c
1016 }
1017 } finally {
1018 fimPedido(e.agentId)
1019 }
1020 })
1021 on('turn.complete', async ($: any, e: any, next: any) => {
1022 const r = await next(e)
1023 await aoTerminarTurno($, e).catch(nada)
1024 return r
1025 })
1026 on('agent.spawn', ($: any, e: any, next: any) => aoNascerAgente($, e, next)).catch(($: any, e: any, next: any) => next(e))
1027 on('tool.call', async ($: any, e: any, next: any) => {
1028 await garantir($).catch(nada)
1029 return aoChamar($, e, next)
1030 }).catch(($: any, e: any, next: any) => next(e))
1031 on('prompt.submit', async ($: any, e: any, next: any) => {
1032 await aoReceberPrompt($, e).catch(nada)
1033 return next(e)
1034 }).catch(($: any, e: any, next: any) => next(e))
1035
1036 // Eventos clássicos: só observam (nunca decidem pela pessoa).
1037 on('classic.PermissionRequest', async ($: any, e: any, next: any) => {
1038 const id = await anotarPermissao($, e).catch(() => '')
1039 const r = await next(e)
1040 if (id && r?.decision) await tirarPendentes($, [id]).catch(nada) // outro gancho já respondeu: não espera ninguém
1041 return r
1042 }).catch(($: any, e: any, next: any) => next(e))
1043 on('classic.PermissionDenied', async ($: any, e: any, next: any) => {
1044 await negarPermissao($, e).catch(nada)
1045 return next(e)
1046 }).catch(($: any, e: any, next: any) => next(e))
1047 on('classic.StopFailure', async ($: any, e: any, next: any) => {
1048 await anotarFalhaApi($, e).catch(nada)
1049 return next(e)
1050 })
1051 on('classic.Stop', async ($: any, e: any, next: any) => {
1052 anotarArquivo(e)
1053 await aoParar($, e).catch(nada)
1054 return next(e)
1055 }).catch(($: any, e: any, next: any) => next(e))
1056 on('classic.SessionStart', async ($: any, e: any, next: any) => {
1057 anotarArquivo(e)
1058 await anotarTitulo($, e.session_title).catch(nada)
1059 return next(e)
1060 })
1061 on('classic.UserPromptSubmit', async ($: any, e: any, next: any) => {
1062 anotarArquivo(e)
1063 await anotarTitulo($, e.session_title).catch(nada)
1064 return next(e)
1065 }).catch(($: any, e: any, next: any) => next(e))
1066
1067 // O render só lê; os botões chamam as ações deste arquivo, presas ao $ do desenho.
1068 on('ui.render', { component: 'Pane', requestId: PANE }, async ($: any, e: any) => {
1069 const m = await lerModelo($)
1070 const acoes: Acoes = {
1071 abrir: id => abrir($, id),
1072 dispensar: ids => dispensar($, ids),
1073 explicar: id => explicar($, id),
1074 parar: id => parar($, id),
1075 alternar: chave => alternar($, chave),
1076 }
1077 return desenharPainel($.ui.resolve(e), e, m, acoes)
1078 })
1079}
1080hooks/desenho.tsx 346 lines1import type { Acao, Acoes, Atencao, Linha, Modelo, ResumoSessao, Trabalhador } from '../types'
2import { LIMITES, arvore, atencoes, calado, horaCurta, mil, modeloCurto, nomeHumano, quemGasta, tempo, veredito } from './regras'
3import { PALETA as P, barraUso, faixa, fita, semSinal, titulo } from './svg'
4
5// Árvore de elementos do painel. Só lê o modelo (nunca grava); botões chamam `acoes` (ações de coleta.ts).
6// Não recebe o $: o motor só segue o $ para funções do próprio hooks module, nunca através de import.
7// Quem chama passa `$.ui.resolve(e)` e as ações já presas ao $ (ver Acoes em types/index.d.ts).
8// Desktop (e demais superfícies remotas) desenha os SVGs; o terminal, que não tem Svg, recebe só texto.
9
10// Janela das raias e da fita: do primeiro trabalho visível até agora, entre 2 e 40 min.
11const janelaDe = (desde: number, agora: number) => Math.min(40 * 60_000, Math.max(2 * 60_000, (agora - desde) * 1.08))
12const PX = 8 // px de SVG por célula
13
14const COR_NIVEL = { ok: P.ok, aviso: P.aviso, pendencia: P.pendencia, neutro: P.texto }
15// Largura e altura explícitas, lidas do próprio SVG: sem elas o quadro encolhe a cada redesenho e o resto pula.
16const tam = (source: string) => {
17 const [, w, h] = /width="([\d.]+)" height="([\d.]+)"/.exec(source) ?? []
18 return { source, ...(w && h ? { width: Number(w), height: Number(h) } : {}) }
19}
20const ROTULO_ACAO = { explicar: 'Explicar', dispensar: 'Dispensar', parar: 'Parar', esperar: 'Esperar' }
21
22const corDe = (t: Trabalhador) =>
23 t.id === 'principal' ? P.texto
24 : t.tipo === 'agy' ? P.gemini
25 : t.tipo === 'codex' ? P.codex
26 : t.tipo === 'comando' ? P.comando
27 : t.tipo === 'pergunta' ? P.pendencia
28 : P.agentes
29const corta = (s: string, n: number) => (s.length > n ? s.slice(0, Math.max(1, n - 1)) + '…' : s)
30const plural = (n: number, um: string, varios: string) => `${n} ${n === 1 ? um : varios}`
31// Saída de comando pode trazer cores ANSI e \r: Code, Markdown e Text só aceitam tab e quebra de linha.
32const limpo = (s: unknown) => String(s ?? '').replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '')
33const recuo = (nivel: number) => (nivel > 0 ? ' '.repeat(nivel - 1) + '└ ' : '')
34const renova = (agora: number, iso?: string) => {
35 if (!iso) return ''
36 const h = horaCurta(iso, agora)
37 return ` · renova ${/^\d/.test(h) ? 'às ' : ''}${h}`
38}
39
40// Só entram as outras sessões trabalhando ou com pendência; as paradas não aparecem (pedido do usuário 08/10).
41function resumoSessoes(ss: ResumoSessao[]): string {
42 const quantas = ss.length === 1 ? '1 outra sessão' : `${ss.length} outras sessões`
43 const pend = ss.filter(s => s.nivel === 'pendencia').length
44 return `${quantas} · ${pend ? plural(pend, 'pendência', 'pendências') : 'em andamento'}`
45}
46
47const estadoSessao = (s: ResumoSessao) =>
48 s.nivel === 'pendencia' ? s.pendencias.join(' · ') || 'pendência'
49 : s.nivel === 'andando' ? (s.frentes ? `em andamento · ${plural(s.frentes, 'frente', 'frentes')}` : 'Claude trabalhando')
50 : 'sem atividade'
51
52export function desenharPainel(el: any, e: any, m: Modelo, acoes: Acoes): any {
53 const { Box, Text, Button, Svg, Client, Code, Markdown } = el
54 const grafico = e.surface !== 'terminal'
55 const larg = Math.max(30, Number(e.props?.bodyColumns ?? e.viewport?.columns ?? 80))
56 const hora = (x: string | number) => horaCurta(x, m.agora)
57
58 if (m.verGlossario) {
59 return (
60 <Box flexDirection="column" gap={1}>
61 <Box flexDirection="row" justifyContent="space-between">
62 <Text bold color={P.texto}>Glossário</Text>
63 <Button key="voltar" label="Voltar" onPress={() => void acoes.alternar('verGlossario')} />
64 </Box>
65 {m.glossario.length === 0 && <Text color={P.apagado}>Nenhum termo ainda.</Text>}
66 {m.glossario.map(g => (
67 <Text color={P.secundario}>
68 <Text bold color={P.texto}>{g.termo}</Text>
69 {`: ${g.definicao}`}
70 </Text>
71 ))}
72 </Box>
73 )
74 }
75
76 const v = veredito(m)
77 const at = atencoes(m)
78 const linhas = arvore(m)
79 const ocioso = v.nivel === 'ocioso'
80 const todos = Object.values(m.trabalhadores)
81
82 // Último sinal contando os filhos que ainda rodam: quem só espera filho ativo não fica "sem sinal".
83 const sinal = (t: Trabalhador, d = 0): number =>
84 d > 8 ? t.ultimoSinal : Math.max(t.ultimoSinal, ...todos.filter(f => f.pai === t.id && f.situacao === 'rodando').map(f => sinal(f, d + 1)))
85
86 // (1) Linha fina: ao vivo e glossário. As cotas ficam só em Uso (pedido do usuário: menos ênfase).
87 const topo = (
88 <Box flexDirection="row" justifyContent="space-between">
89 <Text color={P.apagado} wrap="truncate-end">
90 {ocioso
91 ? `ocioso${m.principal.fimTurno ? ` desde ${hora(m.principal.fimTurno)}` : ''}`
92 : <Text color={P.ok}>● <Text color={P.secundario}>ao vivo</Text></Text>}
93 </Text>
94 <Button plain key="glossario" label="Glossário" onPress={() => void acoes.alternar('verGlossario')} />
95 </Box>
96 )
97
98 // (2) Veredito: manchete grande e os 3 números.
99 const [fundo, borda, corSub] =
100 v.nivel === 'pendencia' ? [P.pendenciaFundo, P.pendenciaBorda, P.pendencia]
101 : v.nivel === 'aviso' ? [P.avisoFundo, P.avisoBorda, P.aviso]
102 : [P.cartao, P.borda, v.nivel === 'andando' ? P.ok : P.secundario]
103 const vered = (
104 <Box flexDirection="column" borderStyle="round" borderColor={borda} backgroundColor={fundo} paddingX={1} gap={1}>
105 {grafico
106 ? <Svg {...tam(titulo(v.manchete, v.sub, P.texto, corSub, (larg - 4) * PX))} alt={`${v.manchete}. ${v.sub}`} />
107 : (
108 <Box flexDirection="column">
109 <Text bold color={P.texto}>{v.manchete}</Text>
110 {v.sub ? <Text color={corSub}>{v.sub}</Text> : null}
111 </Box>
112 )}
113 {!ocioso && (grafico
114 ? (
115 <Box flexDirection="row" gap={1}>
116 {v.tiles.map(t => (
117 <Box flexDirection="column" flexGrow={1} flexShrink={1} width="30%" borderStyle="round" borderColor={P.borda} paddingX={1}>
118 <Text color={P.secundario} wrap="truncate-end">{t.rotulo}</Text>
119 <Text bold color={COR_NIVEL[t.nivel]} wrap="truncate-end">{t.valor}</Text>
120 {t.detalhe ? <Text color={P.apagado} wrap="truncate-end">{t.detalhe}</Text> : null}
121 </Box>
122 ))}
123 </Box>
124 )
125 : (
126 <Text color={P.secundario} wrap="truncate-end">
127 {v.tiles.map((t, i) => (
128 <Text>
129 {i ? ' · ' : ''}
130 {`${t.rotulo} `}
131 <Text bold color={COR_NIVEL[t.nivel]}>{t.valor}</Text>
132 </Text>
133 ))}
134 </Text>
135 ))}
136 </Box>
137 )
138
139 // (3) Outras sessões: uma linha recolhida; o clique abre a lista.
140 const ativas = m.sessoes.filter(s => s.nivel !== 'ocioso')
141 const sessoes = ativas.length > 0 && (
142 <Box flexDirection="column">
143 <Button plain key="sessoes" label={`${m.sessoesAbertas ? '▾' : '▸'} ${resumoSessoes(ativas)}`} onPress={() => void acoes.alternar('sessoesAbertas')} />
144 {m.sessoesAbertas && ativas.map(s => (
145 <Text color={s.nivel === 'pendencia' ? P.pendencia : P.secundario} wrap="truncate-end">{` ${s.nome} · ${estadoSessao(s)}`}</Text>
146 ))}
147 </Box>
148 )
149
150 // (4)(5) Pendências e Avisos: cartões com os botões de cada um.
151 const agir = (acao: Atencao['acoes'][number], a: Atencao) =>
152 acao === 'explicar' ? acoes.explicar(a.quem ?? a.id)
153 : acao === 'parar' ? acoes.parar(a.quem ?? a.id)
154 : acoes.dispensar(a.tipo === 'erro' && a.quem && a.quem !== a.id ? [a.id, a.quem] : [a.id]) // dispensar e esperar: marca como visto
155 const cartao = (a: Atencao, pref: string) => {
156 const [f, b] = a.nivel === 'pendencia' ? [P.pendenciaFundo, P.pendenciaBorda] : [P.avisoFundo, P.avisoBorda]
157 // Contexto e cota não têm idade; "sem sinal há 6 min" já diz a idade no título.
158 const idade = !['contexto', 'cota', 'calado', 'travado'].includes(a.tipo) && a.desde ? `há ${tempo(m.agora - a.desde)}` : ''
159 // Explicar mostra o resultado aqui mesmo; depois de explicado, o botão sai.
160 const quem = a.quem ? m.trabalhadores[a.quem] : undefined
161 const botoes = a.acoes.filter(acao => acao !== 'explicar' || !(quem?.explicacao || quem?.explicando))
162 // Botões na linha do título: o que cresce embaixo (explicação) não os empurra para longe do mouse.
163 const info = [a.detalhe ? limpo(a.detalhe) : '', idade].filter(Boolean).join(' · ')
164 return (
165 <Box flexDirection="column" borderStyle="round" borderColor={b} backgroundColor={f} paddingX={1}>
166 <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap={1}>
167 <Box flexGrow={1} flexShrink={1}>
168 <Text bold color={P.texto} wrap="truncate-end">{limpo(a.titulo)}</Text>
169 </Box>
170 {botoes.length > 0 && (
171 <Box flexDirection="row" gap={1} flexShrink={0}>
172 {botoes.map(acao => (
173 <Button key={`${pref}:${acao}:${a.id}`} label={ROTULO_ACAO[acao]} onPress={() => void agir(acao, a)} />
174 ))}
175 </Box>
176 )}
177 </Box>
178 {info ? <Text color={P.secundario}>{info}</Text> : null}
179 {quem?.explicando ? <Text color={P.apagado}>explicando…</Text> : null}
180 {quem?.explicacao ? <Markdown text={limpo(quem.explicacao)} /> : null}
181 </Box>
182 )
183 }
184 const secao = (nome: string, itens: Atencao[], mais: number, cor: string, pref: string) =>
185 itens.length > 0 && (
186 <Box flexDirection="column" gap={1}>
187 <Text bold color={cor}>{`${nome} · ${itens.length + mais}`}</Text>
188 {itens.map(a => cartao(a, pref))}
189 {mais > 0 ? <Text color={P.apagado}>{`mais ${mais}`}</Text> : null}
190 </Box>
191 )
192
193 // (6) Em execução: nome recuado e, embaixo, tempo e o que faz agora; raia à direita; cartão no hover.
194 const esq = grafico ? Math.min(36, Math.floor(larg * 0.5)) : larg
195 const pxFaixa = Math.max(80, (larg - esq - 2) * PX)
196 // A janela segue o trabalho atual (rodando ou terminado há menos de 2 min); falha antiga não visto não a estica.
197 const atuais = linhas.flatMap(l => (l.tipo === 'trabalhador' && (l.t.situacao === 'rodando' || m.agora - (l.t.fim ?? 0) < 120_000) ? [l.t.inicio] : []))
198 const visiveis = atuais.length ? atuais : linhas.flatMap(l => (l.tipo === 'trabalhador' ? [l.t.inicio] : []))
199 const JANELA = janelaDe(visiveis.length ? Math.min(...visiveis) : m.agora, m.agora)
200 // Workflow diz quantos agentes já entregaram, em vez do "agora" de quando começou.
201 const fazAgora = (t: Trabalhador) => {
202 if (t.tipo !== 'workflow') return limpo(t.agora)
203 const ags = todos.filter(f => f.pai === t.id && f.tipo === 'agente-wf')
204 return ags.length ? `${ags.filter(f => f.situacao !== 'rodando').length} de ${ags.length} prontos` : 'iniciando os agentes'
205 }
206 const linhaTrab = (l: Extract<Linha, { tipo: 'trabalhador' }>) => {
207 const t = l.t
208 const cor = corDe(t)
209 const rodando = t.situacao === 'rodando'
210 const tv = { ...t, ultimoSinal: sinal(t) }
211 const silencio = rodando ? calado(tv, m.agora) : 0
212 const nome = `${l.nome}${l.ordinal ? ` #${l.ordinal}` : ''}`
213 const duracao = tempo((t.fim ?? m.agora) - t.inicio)
214 // Terminal: o que a raia diria, em palavras (o fim já aparece na segunda linha).
215 const estado =
216 silencio > LIMITES.calado ? [`sem sinal há ${semSinal(silencio)}`, P.aviso]
217 : rodando && t.pct !== undefined ? [`${Math.round(t.pct)}%`, P.texto]
218 : ['', '']
219 const tokens = (t.tokensNovos ?? 0) + (t.tokensCache ?? 0) > 0
220 return (
221 <Box key={`linha:${t.id}`} flexDirection="column" hover={{ backgroundColor: P.cartao }}>
222 <Box flexDirection="row">
223 <Box flexDirection="column" width={esq}>
224 <Box flexDirection="row" gap={1}>
225 <Text color={P.apagado}>
226 {recuo(l.nivel)}
227 <Text color={cor}>●</Text>
228 </Text>
229 <Button plain key={`abrir:${t.id}`} label={corta(nome, Math.max(10, esq - l.nivel * 2 - 4))} onPress={() => void acoes.abrir(t.id)} />
230 {!grafico && estado[0] ? <Text color={estado[1]}>{estado[0]}</Text> : null}
231 </Box>
232 <Box flexDirection="row">
233 <Text color={P.apagado}>{' '.repeat(l.nivel + 1)}</Text>
234 {rodando
235 ? <Client key={`tempo:${t.id}`} module="./relogio.tsx" props={{ desde: t.inicio, base: m.agora }} />
236 : <Text color={P.apagado}>{duracao}</Text>}
237 <Text color={t.situacao === 'falhou' ? P.pendencia : P.apagado} wrap="truncate-end">
238 {` · ${[quemGasta(t, todos.filter(f => f.pai === t.id)), rodando ? fazAgora(t) : t.situacao === 'falhou' ? 'falhou' : 'concluído'].filter(Boolean).join(' · ')}`}
239 </Text>
240 </Box>
241 </Box>
242 {grafico ? <Svg {...tam(faixa(tv, m.agora, JANELA, pxFaixa, cor))} alt={`${nome}: ${limpo(t.agora)}`} /> : null}
243 </Box>
244 <Box position="absolute" top={2} left={4} display="none" hover={{ display: 'flex' }} flexDirection="column" borderStyle="round" borderColor={P.borda} backgroundColor={P.cartao} paddingX={1}>
245 {t.modelo ? <Text color={P.secundario}>{`modelo ${modeloCurto(t.modelo)}`}</Text> : null}
246 {tokens ? <Text color={P.secundario}>{`tokens ${mil(t.tokensNovos ?? 0)} novos · ${mil(t.tokensCache ?? 0)} em cache`}</Text> : null}
247 <Text color={P.secundario}>{`tempo ${duracao}`}</Text>
248 {t.rotulo ? <Text color={P.apagado} wrap="truncate-end">{limpo(t.rotulo)}</Text> : null}
249 </Box>
250 </Box>
251 )
252 }
253 const linhaResumo = (l: Extract<Linha, { tipo: 'resumo' }>) => (
254 <Text color={l.situacao === 'falhou' ? P.pendencia : l.situacao === 'rodando' ? P.secundario : P.ok}>{`${recuo(l.nivel)}${l.texto}`}</Text>
255 )
256 const execucao = !ocioso && linhas.length > 0 && (
257 <Box flexDirection="column">
258 <Text bold color={P.secundario}>Em execução</Text>
259 {linhas.map(l => (l.tipo === 'resumo' ? linhaResumo(l) : linhaTrab(l)))}
260 </Box>
261 )
262
263 // Detalhe do trabalhador aberto: últimas ações, cauda da saída, explicação e botões.
264 const sel = m.aberto ? m.trabalhadores[m.aberto] : undefined
265 const detalhe = sel && (
266 <Box flexDirection="column" borderStyle="round" borderColor={P.borda} backgroundColor={P.cartao} paddingX={1} gap={1}>
267 <Box flexDirection="row" justifyContent="space-between">
268 <Text bold color={P.texto} wrap="truncate-end">{nomeHumano(sel)}</Text>
269 <Button plain key={`fechar:${sel.id}`} label="Fechar" onPress={() => void acoes.abrir(sel.id)} />
270 </Box>
271 {sel.tarefa ? <Text color={P.secundario}>{limpo(sel.tarefa)}</Text> : null}
272 {sel.acoes.length > 0 && <Code source={sel.acoes.slice(-8).map(a => `${hora(a.t)} ${a.erro ? '✕' : '·'} ${limpo(a.texto)}`).join('\n')} wrap="truncate-end" />}
273 {(sel.cauda?.length ?? 0) > 0 && <Code source={(sel.cauda ?? []).slice(-12).map(limpo).join('\n')} wrap="truncate-end" />}
274 {sel.explicacao ? <Markdown text={limpo(sel.explicacao)} /> : sel.explicando ? <Text color={P.apagado}>explicando…</Text> : null}
275 <Box flexDirection="row" gap={1}>
276 {!sel.explicacao && !sel.explicando && <Button key={`explicar:${sel.id}`} label="Explicar" onPress={() => void acoes.explicar(sel.id)} />}
277 {sel.situacao === 'rodando' && <Button key={`parar:${sel.id}`} label="Parar" onPress={() => void acoes.parar(sel.id)} />}
278 </Box>
279 </Box>
280 )
281
282 // (7) Histórico: fita das ações do principal; aberto, lista agrupada (miúdas juntas) com os trabalhos concluídos.
283 const nAcoes = m.principal.acoes.length
284 type Item = { t: number; w?: Trabalhador; a?: Acao; n?: number }
285 const lista = (): Item[] => {
286 const itens: Item[] = [
287 ...todos.filter(w => w.situacao !== 'rodando').map(w => ({ t: w.fim ?? w.inicio, w })),
288 ...m.principal.acoes.map(a => ({ t: a.t, a })),
289 ].sort((x, y) => y.t - x.t)
290 const grupos: Item[] = []
291 for (const it of itens) {
292 const miuda = !!it.a && !it.a.erro && (it.a.tipo === 'leitura' || it.a.tipo === 'busca' || it.a.tipo === 'edicao')
293 const ult = grupos[grupos.length - 1]
294 if (miuda && ult?.n) ult.n++
295 else grupos.push(miuda ? { t: it.t, n: 1 } : it)
296 }
297 return grupos.slice(0, 20)
298 }
299 const historico = (nAcoes > 0 || todos.some(w => w.situacao !== 'rodando')) && (
300 <Box flexDirection="column">
301 {grafico && !ocioso && nAcoes > 0 ? <Svg {...tam(fita(m.principal.acoes, m.agora, larg * PX))} alt={`Fita das últimas ${nAcoes} ações`} /> : null}
302 <Button plain key="historico" label={`${m.historicoAberto ? '▾' : '▸'} Histórico · ${plural(nAcoes, 'ação', 'ações')}`} onPress={() => void acoes.alternar('historicoAberto')} />
303 {m.historicoAberto && lista().map(g =>
304 g.w ? (
305 <Button plain key={`hist:${g.w.id}`} label={` ${hora(g.t)} ${g.w.situacao === 'falhou' ? '✕' : '✓'} ${nomeHumano(g.w)} · ${tempo((g.w.fim ?? g.t) - g.w.inicio)}`} onPress={() => void acoes.abrir(g.w!.id)} />
306 ) : g.n ? (
307 <Text color={P.apagado}>{` ${hora(g.t)} · ${g.n === 1 ? '1 leitura, busca ou edição' : `${g.n} leituras, buscas e edições`}`}</Text>
308 ) : (
309 <Text color={g.a?.erro ? P.pendencia : P.secundario} wrap="truncate-end">{` ${hora(g.t)} ${g.a?.erro ? '✕' : '·'} ${limpo(g.a?.texto)}`}</Text>
310 ),
311 )}
312 </Box>
313 )
314
315 // (8) Uso: duas barras finas e discretas, com a hora em que renovam.
316 const linhaUso = (rotulo: string, pct?: number, iso?: string) =>
317 pct !== undefined && (
318 <Box flexDirection="row" gap={1} alignItems="center">
319 <Text color={P.apagado}>{rotulo.padEnd(6)}</Text>
320 {grafico ? <Svg {...tam(barraUso(pct, 96, pct >= 80 ? P.aviso : P.secundario))} alt={`${rotulo} ${Math.round(pct)}%`} /> : null}
321 <Text color={pct >= 80 ? P.aviso : P.apagado}>{`${Math.round(pct)}%${renova(m.agora, iso)}`}</Text>
322 </Box>
323 )
324 const uso = (m.uso.cota5h !== undefined || m.uso.semana !== undefined) && (
325 <Box flexDirection="column">
326 <Text color={P.apagado}>Uso</Text>
327 {linhaUso('5h', m.uso.cota5h, m.uso.renova5h)}
328 {linhaUso('7 dias', m.uso.semana, m.uso.renovaSemana)}
329 </Box>
330 )
331
332 return (
333 <Box flexDirection="column" gap={1}>
334 {topo}
335 {vered}
336 {sessoes}
337 {secao('Pendências', at.pendencias, at.maisPendencias, P.pendencia, 'pend')}
338 {secao('Avisos', at.avisos, at.maisAvisos, P.aviso, 'aviso')}
339 {execucao}
340 {detalhe}
341 {historico}
342 {uso}
343 </Box>
344 )
345}
346hooks/regras.ts 478 lines1// Regras do painel: funções PURAS que derivam veredito, atenções, árvore e textos do Modelo.
2// Sem $, sem relógio próprio (tudo parte de m.agora), só tipos de '../types'. Textos adultos e sóbrios.
3import type { Acao, Atencao, Linha, Modelo, ResumoSessao, Trabalhador, Veredito } from '../types'
4
5export const LIMITES = { calado: 90_000, travado: 300_000, recolher: 10_000, raias: 8, avisos: 3, pendencias: 3, contexto: 80 }
6
7// ---------- formatação ----------
8
9const plural = (n: number, um: string, varios: string) => `${n} ${n === 1 ? um : varios}`
10const corta = (s: string, max: number) => (s.length > max ? s.slice(0, max - 1).trimEnd() + '…' : s)
11// Só o nome do arquivo, sem caminho nem aspas.
12const base = (s: unknown) => String(s ?? '').trim().split(/[\\/]/).pop()!.replace(/["']/g, '').slice(0, 40)
13
14/** "45s", "2 min", "7min 47s", "12 min", "1h 05min". */
15export function tempo(ms: number): string {
16 const s = Math.max(0, Math.round(ms / 1000))
17 if (s < 60) return `${s}s`
18 const m = Math.floor(s / 60)
19 if (m < 10) return s % 60 ? `${m}min ${s % 60}s` : `${m} min`
20 if (m < 60) return `${m} min`
21 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}min`
22}
23// Em frases ("sem sinal há 6 min") os segundos só atrapalham: arredonda para baixo no minuto.
24const grosso = (ms: number) => (ms < 60_000 ? tempo(ms) : tempo(Math.floor(ms / 60_000) * 60_000))
25
26/** "260 mil", "8,4 mi". */
27export function mil(n: number): string {
28 if (n < 1000) return String(Math.round(n))
29 if (n < 999_500) return `${Math.round(n / 1000)} mil`
30 return `${(n / 1e6).toFixed(1).replace(/\.0$/, '').replace('.', ',')} mi`
31}
32
33const DIAS = ['dom', 'seg', 'ter', 'qua', 'qui', 'sex', 'sáb']
34/** "19:20" se for hoje; "seg 09:00" em outro dia. `agora` só existe para o teste. */
35export function horaCurta(iso: string | number, agora: number = Date.now()): string {
36 const d = new Date(iso)
37 if (Number.isNaN(d.getTime())) return ''
38 const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
39 return d.toDateString() === new Date(agora).toDateString() ? hm : `${DIAS[d.getDay()]} ${hm}`
40}
41// "às 19:20" (hoje) ou "seg 09:00": completa frases como "Concluído …" e "renova …".
42const quando = (t: string | number, agora: number) => {
43 const h = horaCurta(t, agora)
44 return /^\d/.test(h) ? `às ${h}` : h
45}
46
47// ---------- nomes ----------
48
49const SUBAGENTES: Record<string, string> = {
50 explore: 'Pesquisa',
51 plan: 'Planejamento',
52 'general-purpose': 'Assistente',
53 'codex:codex-rescue': 'Revisor Codex',
54 'claude-code-guide': 'Consulta',
55}
56
57// "face_branca" → "Face branca"; caminho de script vira só o nome do arquivo.
58function humanizar(s: string): string {
59 let x = String(s ?? '').trim()
60 if (/^(?:[A-Za-z]:)?[\\/]|[\\/][^\\/\s]+\.\w+$/.test(x)) x = x.split(/[\\/]/).pop()!.replace(/\.\w+$/, '')
61 x = x.replace(/([\p{L}\p{N}])[_-]+(?=[\p{L}\p{N}])/gu, '$1 ')
62 return x.charAt(0).toUpperCase() + x.slice(1)
63}
64
65/** "Pesquisa", "Assistente Gemini", "Revisor Codex", "Comando: Fatia o modelo", "Face branca". */
66/** "claude-sonnet-5-5" → "Sonnet 5.5"; "Gemini 3.8 Flash (High)" → "Gemini 3.8 Flash"; outros como vieram. */
67export function modeloCurto(m: string): string {
68 const c = /(opus|sonnet|haiku|fable)[-\s]?(\d+)?(?:[-.](\d+))?/i.exec(m)
69 if (c) {
70 const nome = c[1]![0]!.toUpperCase() + c[1]!.slice(1).toLowerCase()
71 return c[2] ? `${nome} ${c[2]}${c[3] ? '.' + c[3] : ''}` : nome
72 }
73 return m.replace(/\s*\((high|low|medium|thinking)\)/i, '').trim()
74}
75
76/** Quem trabalha e quanto gastou, para a 2ª linha da raia: "Sonnet 5.5 · 71 mil tokens". Vazio se não há o que dizer. */
77export function quemGasta(t: Trabalhador, filhos: Trabalhador[] = []): string {
78 const tok = (x: Trabalhador) => (x.tokensNovos ?? 0) + (x.tokensCache ?? 0)
79 if (t.tipo === 'workflow') {
80 const soma = filhos.filter(f => f.tipo === 'agente-wf').reduce((s, f) => s + tok(f), 0)
81 return soma ? `${mil(soma)} tokens` : ''
82 }
83 if (t.tipo === 'comando' || t.tipo === 'pergunta') return ''
84 const modelo = t.modelo ? modeloCurto(t.modelo) : t.tipo === 'codex' ? 'modelo padrão' : ''
85 const tokens = t.tipo === 'codex' ? '' : tok(t) ? `${mil(tok(t))} tokens` : ''
86 return [modelo, tokens].filter(Boolean).join(' · ')
87}
88
89export function nomeHumano(t: Trabalhador): string {
90 const r = String(t.rotulo ?? '')
91 switch (t.tipo) {
92 case 'subagente':
93 return SUBAGENTES[r.toLowerCase()] ?? (humanizar(r.split(':').pop()!) || 'Assistente')
94 case 'agente-wf':
95 return humanizar(r) || 'Agente'
96 case 'workflow':
97 return humanizar(r) || 'Workflow'
98 case 'comando':
99 return r ? `Comando: ${corta(r, 44)}` : 'Comando'
100 case 'agy':
101 // O agy também roda Claude quando a cota do Gemini acaba: não rotular errado.
102 return /claude|sonnet|opus|haiku/i.test(t.modelo ?? '') ? 'Assistente Claude' : 'Assistente Gemini'
103 case 'codex':
104 return 'Revisor Codex'
105 case 'pergunta':
106 return 'Pergunta ao usuário'
107 default:
108 return humanizar(r)
109 }
110}
111
112/** #1/#2 só entre irmãos (mesmo pai) de mesmo nome humano, na ordem de início. */
113export function ordinais(ts: Trabalhador[]): Map<string, number> {
114 const grupos = new Map<string, Trabalhador[]>()
115 for (const t of ts) {
116 const k = `${t.pai}\u0000${nomeHumano(t)}`
117 const g = grupos.get(k)
118 if (g) g.push(t)
119 else grupos.set(k, [t])
120 }
121 const out = new Map<string, number>()
122 for (const g of grupos.values()) {
123 if (g.length < 2) continue
124 g.sort((a, b) => a.inicio - b.inicio || (a.id < b.id ? -1 : 1)).forEach((t, i) => out.set(t.id, i + 1))
125 }
126 return out
127}
128
129/** Milissegundos de silêncio; 0 se não está rodando. */
130export function calado(t: Trabalhador, agora: number): number {
131 return t.situacao === 'rodando' ? Math.max(0, agora - t.ultimoSinal) : 0
132}
133
134// ---------- o que o agente faz, em português simples ----------
135
136/** Ferramenta + entrada → tipo da ação e texto sem caminho completo ("lendo corners.png"). */
137export function acaoDe(ferramenta: string, entrada: any): { tipo: Acao['tipo']; texto: string } {
138 const e = entrada ?? {}
139 const arq = base(e.file_path ?? e.path ?? e.notebook_path)
140 const desc = e.description ? String(e.description) : ''
141 switch (ferramenta) {
142 case 'Read':
143 case 'NotebookRead':
144 return { tipo: 'leitura', texto: arq ? `lendo ${arq}` : 'lendo um arquivo' }
145 case 'Edit':
146 case 'MultiEdit':
147 case 'NotebookEdit':
148 return { tipo: 'edicao', texto: arq ? `editando ${arq}` : 'editando um arquivo' }
149 case 'Write':
150 return { tipo: 'edicao', texto: arq ? `escrevendo ${arq}` : 'escrevendo um arquivo' }
151 case 'Grep':
152 return { tipo: 'busca', texto: 'procurando nos arquivos' }
153 case 'Glob':
154 return { tipo: 'busca', texto: 'listando arquivos' }
155 case 'Bash':
156 case 'PowerShell':
157 return { tipo: 'comando', texto: desc ? corta(desc, 60) : 'rodando um comando' }
158 case 'WebFetch':
159 case 'WebSearch':
160 return { tipo: 'web', texto: 'pesquisando na internet' }
161 case 'Agent':
162 case 'Task':
163 return { tipo: 'agente', texto: desc ? `chamando um ajudante · ${corta(desc, 40)}` : 'chamando um ajudante' }
164 case 'Workflow':
165 return { tipo: 'agente', texto: 'iniciando um workflow' }
166 case 'TodoWrite':
167 case 'TaskCreate':
168 case 'TaskUpdate':
169 case 'TaskList':
170 return { tipo: 'outro', texto: 'atualizando o plano' }
171 case 'AskUserQuestion':
172 return { tipo: 'outro', texto: 'fazendo uma pergunta' }
173 case 'ExitPlanMode':
174 return { tipo: 'outro', texto: 'apresentando o plano' }
175 case 'ToolSearch':
176 return { tipo: 'outro', texto: 'buscando ferramentas' }
177 case 'Skill':
178 return { tipo: 'outro', texto: 'usando uma skill' }
179 default:
180 return { tipo: 'outro', texto: ferramenta.startsWith('mcp__') ? 'usando uma ferramenta externa' : `usando ${ferramenta}` }
181 }
182}
183
184// Passos do agy ("tool: parâmetro", "pensando"…) em português simples. O que não estiver aqui passa como veio.
185const PASSOS: [RegExp, (m: RegExpExecArray) => string][] = [
186 [/^run_command/, () => 'rodando um comando'],
187 [/^(?:view_file|read_file)\w*:?\s*(.*)/, m => `lendo ${base(m[1]) || 'um arquivo'}`],
188 [/^list_dir/, () => 'listando a pasta'],
189 [/^(?:grep_search|search_files|find_by_name)/, () => 'procurando nos arquivos'],
190 [/^(?:write_to_file|create_file)\w*:?\s*(.*)/, m => `escrevendo ${base(m[1]) || 'um arquivo'}`],
191 [/^(?:replace_file_content|multi_replace_file_content|edit_file)\w*:?\s*(.*)/, m => `editando ${base(m[1]) || 'um arquivo'}`],
192 [/^(?:search_web|read_url_content|web_)/, () => 'pesquisando na internet'],
193 [/^browser_/, () => 'usando o navegador'],
194 [/^generate_image/, () => 'gerando uma imagem'],
195 [/^ask_/, () => 'esperando permissão'],
196 [/^pensando/, () => 'pensando'],
197 [/^escrevendo a resposta/, () => 'escrevendo a resposta'],
198]
199
200/** "view_file: main.py" → "lendo main.py". Ferramenta desconhecida vira "usando <nome>". */
201export function traduzirPassoAgy(passo: string): string {
202 const p = String(passo ?? '').trim()
203 for (const [re, f] of PASSOS) {
204 const m = re.exec(p)
205 if (m) return f(m)
206 }
207 const desconhecida = /^([a-z]+(?:_\w+)+)/i.exec(p)
208 return desconhecida ? `usando ${desconhecida[1]!.replace(/_/g, ' ')}` : p
209}
210
211// ---------- fatos sobre os trabalhadores ----------
212
213function indice(m: Modelo) {
214 const lista = Object.values(m.trabalhadores)
215 const filhos = new Map<string, Trabalhador[]>()
216 for (const t of lista) {
217 const g = filhos.get(t.pai)
218 if (g) g.push(t)
219 else filhos.set(t.pai, [t])
220 }
221 return { lista, filhos }
222}
223
224type Filhos = Map<string, Trabalhador[]>
225
226// Algum descendente de t cumpre o teste? (guarda contra ciclo, caso o estado venha torto)
227function desce(filhos: Filhos, t: Trabalhador, teste: (f: Trabalhador) => boolean, visto = new Set<string>()): boolean {
228 for (const f of filhos.get(t.id) ?? []) {
229 if (visto.has(f.id)) continue
230 visto.add(f.id)
231 if (teste(f) || desce(filhos, f, teste, visto)) return true
232 }
233 return false
234}
235
236const rodando = (t: Trabalhador) => t.situacao === 'rodando'
237
238// Falha que merece aviso: agentes, workflow, agy, Codex, comando ≥ 5 s ou em fundo. Comando curto com exit≠0 não.
239function importante(t: Trabalhador, agora: number): boolean {
240 if (t.tipo === 'pergunta') return false
241 if (t.tipo !== 'comando') return true
242 return !!t.emFundo || (t.fim ?? agora) - t.inicio >= 5000
243}
244
245type Estado = 'ativo' | 'calado' | 'travado' | 'espera'
246
247// ---------- atenções ----------
248
249const PESO_PEND: Record<string, number> = { permissao: 0, pergunta: 0, plano: 0, 'falha-api': 1, travado: 2, sessao: 3 }
250const PESO_AVISO: Record<string, number> = { erro: 0, calado: 1, contexto: 2, cota: 3 }
251
252// Tudo o que pede atenção, ordenado por gravidade e depois por idade, mais as "frentes" de onde vêm os números.
253// `comSessoes` = false para o resumo que esta sessão publica (não repassar pendência alheia).
254function coletar(m: Modelo, comSessoes = true) {
255 const { lista, filhos } = indice(m)
256 const ord = ordinais(lista)
257 const vistos = new Set(m.vistos)
258 const esperaPessoa = new Set(Object.values(m.pendentes).map(p => p.quem))
259 const nome = (t: Trabalhador) => nomeHumano(t) + (ord.has(t.id) ? ` #${ord.get(t.id)}` : '')
260 const pend: Atencao[] = []
261 const avis: Atencao[] = []
262 const frentes: { t: Trabalhador; est: Estado }[] = []
263
264 for (const p of Object.values(m.pendentes)) {
265 if (p.tipo === 'falha-api' && vistos.has(p.id)) continue
266 pend.push({ id: p.id, nivel: 'pendencia', tipo: p.tipo, titulo: p.titulo, detalhe: p.detalhe, quem: p.quem, desde: p.desde, acoes: p.tipo === 'falha-api' ? ['dispensar'] : [] })
267 }
268
269 for (const t of lista) {
270 if (t.situacao === 'falhou' && !vistos.has(t.id) && importante(t, m.agora)) {
271 let detalhe: string | undefined
272 for (const a of [...t.acoes].reverse()) if (a.erro) { detalhe = a.texto; break }
273 avis.push({ id: t.id, nivel: 'aviso', tipo: 'erro', titulo: `${nome(t)} falhou`, detalhe, quem: t.id, desde: t.fim ?? t.ultimoSinal, acoes: ['explicar', 'dispensar'] })
274 }
275 // Quem só espera filhos ativos não é "frente" nem fica calado: os filhos é que mostram o sinal.
276 if (!rodando(t) || t.tipo === 'pergunta' || (filhos.get(t.id) ?? []).some(rodando)) continue
277 const c = calado(t, m.agora)
278 const est: Estado = esperaPessoa.has(t.id) ? 'espera' : c > LIMITES.travado ? 'travado' : c > LIMITES.calado ? 'calado' : 'ativo'
279 frentes.push({ t, est })
280 if (est === 'travado' && !vistos.has(`travado:${t.id}`)) {
281 pend.push({ id: `travado:${t.id}`, nivel: 'pendencia', tipo: 'travado', titulo: `${nome(t)} sem sinal há ${grosso(c)}`, quem: t.id, desde: t.ultimoSinal, acoes: ['parar', 'esperar'] })
282 } else if (est === 'calado' && !vistos.has(`calado:${t.id}`)) {
283 avis.push({ id: `calado:${t.id}`, nivel: 'aviso', tipo: 'calado', titulo: `${nome(t)} sem sinal há ${grosso(c)}`, quem: t.id, desde: t.ultimoSinal, acoes: ['esperar', 'parar'] })
284 }
285 }
286
287 const ctx = m.uso.contextoPct
288 if (ctx !== undefined && ctx >= LIMITES.contexto) {
289 const id = `contexto:${Math.floor(ctx / 10) * 10}`
290 if (!vistos.has(id)) avis.push({ id, nivel: 'aviso', tipo: 'contexto', titulo: `Contexto em ${Math.round(ctx)}% · a conversa será resumida em breve`, desde: m.agora, acoes: ['dispensar'] })
291 }
292 for (const [chave, rotulo, pct, renova] of [
293 ['cota5h', 'Cota de 5h', m.uso.cota5h, m.uso.renova5h],
294 ['cotaSemana', 'Cota de 7 dias', m.uso.semana, m.uso.renovaSemana],
295 ] as const) {
296 if (pct === undefined || pct < LIMITES.contexto) continue
297 // Um aviso por faixa (80, 90, 100) em cada janela da cota: a hora de renovação separa as janelas,
298 // senão o "visto" dos 80% de ontem calaria os 80% de hoje.
299 const id = `${chave}:${renova ?? ''}:${Math.floor(pct / 10) * 10}`
300 if (vistos.has(id)) continue
301 avis.push({ id, nivel: 'aviso', tipo: 'cota', titulo: `${rotulo} em ${Math.round(pct)}%` + (renova ? ` · renova ${quando(renova, m.agora)}` : ''), desde: m.agora, acoes: ['dispensar'] })
302 }
303
304 if (comSessoes) {
305 for (const s of m.sessoes) {
306 if (s.nivel !== 'pendencia') continue
307 pend.push({ id: `sessao:${s.id}`, nivel: 'pendencia', tipo: 'sessao', titulo: `Sessão ${s.nome}: ${s.pendencias[0] ?? 'aguarda resposta'}`, detalhe: s.pendencias.slice(1).join(' · ') || undefined, desde: 0, acoes: [] }) // desde 0 = não se sabe (o desenho esconde a idade)
308 }
309 }
310
311 pend.sort((a, b) => (PESO_PEND[a.tipo] ?? 9) - (PESO_PEND[b.tipo] ?? 9) || a.desde - b.desde)
312 avis.sort((a, b) => (PESO_AVISO[a.tipo] ?? 9) - (PESO_AVISO[b.tipo] ?? 9) || a.desde - b.desde)
313 return { pend, avis, frentes, lista, filhos }
314}
315
316/** Pendências (máx 3) e avisos (máx 3), com o que sobrou contado em "mais N". */
317export function atencoes(m: Modelo): { pendencias: Atencao[]; avisos: Atencao[]; maisPendencias: number; maisAvisos: number } {
318 const { pend, avis } = coletar(m)
319 return {
320 pendencias: pend.slice(0, LIMITES.pendencias),
321 avisos: avis.slice(0, LIMITES.avisos),
322 maisPendencias: Math.max(0, pend.length - LIMITES.pendencias),
323 maisAvisos: Math.max(0, avis.length - LIMITES.avisos),
324 }
325}
326
327// ---------- veredito ----------
328
329const TIPO_PEND: Record<string, string> = { permissao: 'permissão', pergunta: 'pergunta', plano: 'plano', 'falha-api': 'falha de API', travado: 'sem sinal', sessao: 'outra sessão' }
330
331function assunto(m: Modelo, lista: Trabalhador[]): string {
332 const wf = lista.find(t => t.tipo === 'workflow' && rodando(t))
333 const w = wf ? m.workflows[wf.id] : undefined
334 // Descrição do workflow até a primeira pausa (":", "—", ";"), cortada em palavra inteira; o nome técnico só na falta dela.
335 const desc = w?.desc?.split(/\s*[:;—]\s/)[0]?.trim()
336 const txt = desc || (w && humanizar(w.nome)) || (wf && humanizar(wf.rotulo)) || m.principal.titulo || 'trabalho do Claude'
337 return txt.length > 40 ? txt.slice(0, 40).replace(/\s+\S*$/, '') + '…' : txt
338}
339
340function ultimoTrabalho(m: Modelo, lista: Trabalhador[]): string {
341 if (!lista.length) return m.principal.fimTurno ? 'sem ajudantes' : 'sem trabalho em curso'
342 const ag = lista.filter(t => ['subagente', 'agente-wf', 'agy', 'codex'].includes(t.tipo)).length
343 const cm = lista.filter(t => t.tipo === 'comando').length
344 const ini = Math.min(...lista.map(t => t.inicio))
345 const fim = Math.max(...lista.map(t => t.fim ?? t.ultimoSinal))
346 return [ag && plural(ag, 'agente', 'agentes'), cm && plural(cm, 'comando', 'comandos'), tempo(fim - ini)].filter(Boolean).join(' · ')
347}
348
349type Tile = Veredito['tiles'][number]
350
351function progresso(m: Modelo, lista: Trabalhador[], filhos: Filhos): Tile {
352 const rotulo = 'Progresso'
353 const p = m.principal.plano
354 if (p && p.total > 0) {
355 if (p.feitos >= p.total) return { rotulo, valor: `${p.total} de ${p.total}`, detalhe: 'plano concluído', nivel: 'ok' }
356 return { rotulo, valor: `passo ${p.feitos + 1} de ${p.total}`, detalhe: p.atual ? corta(p.atual, 40) : 'plano do Claude', nivel: 'neutro' }
357 }
358 const wf = lista.filter(t => t.tipo === 'workflow' && rodando(t)).sort((a, b) => a.inicio - b.inicio)[0]
359 const ags = wf ? (filhos.get(wf.id) ?? []).filter(t => t.tipo === 'agente-wf') : []
360 if (ags.length) {
361 const prontos = ags.filter(t => !rodando(t)).length
362 const fase = [...ags].reverse().find(t => rodando(t) && t.fase)?.fase
363 return { rotulo, valor: `${prontos} de ${ags.length} prontos`, detalhe: fase ? corta(fase, 40) : 'workflow', nivel: 'neutro' }
364 }
365 const cmd = lista.find(t => t.tipo === 'comando' && rodando(t) && t.pct !== undefined)
366 if (cmd) return { rotulo, valor: `${Math.round(cmd.pct!)}%`, detalhe: corta(nomeHumano(cmd), 40), nivel: 'neutro' }
367 return { rotulo, valor: '—', detalhe: 'sem plano declarado', nivel: 'neutro' }
368}
369
370/** Manchete, subtítulo e os 3 tiles (Pendências, Atividade, Progresso), todos derivados dos mesmos números. */
371export function veredito(m: Modelo): Veredito {
372 const { pend, avis, frentes, lista, filhos } = coletar(m)
373 const np = pend.length
374 const na = avis.length
375 const n = frentes.length
376 const semSinal = frentes.filter(f => f.est === 'calado' || f.est === 'travado').length
377 const espera = frentes.filter(f => f.est === 'espera').length
378 const ativos = n - semSinal - espera
379 const algoRoda = n > 0 || m.principal.ocupado
380 const nivel: Veredito['nivel'] = np ? 'pendencia' : na ? 'aviso' : algoRoda ? 'andando' : 'ocioso'
381
382 const concluido = m.principal.fimTurno ? `Concluído ${quando(m.principal.fimTurno, m.agora)}` : 'Sem atividade'
383 const frentesTxt = n ? plural(n, 'frente', 'frentes') : 'Claude trabalhando'
384 let manchete: string
385 let sub: string
386 if (nivel === 'pendencia') {
387 manchete = plural(np, 'pendência', 'pendências')
388 sub = pend.slice(0, 2).map(a => corta(a.titulo, 46)).join(' · ')
389 } else if (algoRoda) {
390 manchete = `Em andamento · ${assunto(m, lista)}`
391 sub = `${frentesTxt} · ${na ? plural(na, 'aviso', 'avisos') : 'sem pendências'}`
392 } else {
393 manchete = concluido
394 sub = [na ? plural(na, 'aviso', 'avisos') : '', ultimoTrabalho(m, lista)].filter(Boolean).join(' · ')
395 }
396
397 const pendencias: Tile = {
398 rotulo: 'Pendências',
399 valor: String(np),
400 detalhe: np ? [...new Set(pend.map(a => TIPO_PEND[a.tipo]))].slice(0, 3).join(', ') : na ? plural(na, 'aviso', 'avisos') : 'sem avisos',
401 nivel: np ? 'pendencia' : na ? 'aviso' : 'ok',
402 }
403 const haTrabalho = lista.length > 0 || !!m.principal.fimTurno
404 const atividade: Tile = n
405 ? {
406 rotulo: 'Atividade',
407 valor: semSinal ? `${n - semSinal} de ${n} com sinal` : `${ativos} trabalhando`,
408 detalhe: [semSinal && `${semSinal} sem sinal`, espera && `${espera} aguardando resposta`].filter(Boolean).join(', ') || 'todos com sinal',
409 nivel: semSinal ? 'aviso' : 'ok',
410 }
411 : m.principal.ocupado
412 ? { rotulo: 'Atividade', valor: 'em andamento', detalhe: 'sem ajudantes', nivel: 'ok' }
413 : haTrabalho
414 ? { rotulo: 'Atividade', valor: 'concluído', detalhe: m.principal.fimTurno ? quando(m.principal.fimTurno, m.agora) : 'sem trabalho em curso', nivel: 'ok' }
415 : { rotulo: 'Atividade', valor: '—', detalhe: 'sem atividade', nivel: 'neutro' }
416
417 return { nivel, manchete, sub, tiles: [pendencias, atividade, progresso(m, lista, filhos)] }
418}
419
420/** Resumo desta sessão para as outras verem: só o que é daqui (sem repassar pendência de terceiros). */
421export function resumoSessao(m: Modelo, id: string, nome: string): ResumoSessao {
422 const { pend, frentes } = coletar(m, false)
423 const nivel = pend.length ? 'pendencia' : frentes.length || m.principal.ocupado ? 'andando' : 'ocioso'
424 return { id, nome, nivel, frentes: frentes.length, pendencias: pend.slice(0, 5).map(a => corta(a.titulo, 60)), t: m.agora }
425}
426
427// ---------- árvore "Em execução" ----------
428
429const textoResumo = (sit: 'feito' | 'rodando' | 'falhou', n: number) =>
430 sit === 'rodando' ? `+${n} em execução` : sit === 'falhou' ? `+${n} com falha` : n === 1 ? '+1 pronto' : `+${n} prontos`
431
432/**
433 * Pré-ordem por pai, rodando primeiro. No máximo LIMITES.raias linhas de trabalhador: as raias vão por
434 * prioridade (falha nova, agy/Codex/comando, subagente, agente de workflow, recém-concluído), sempre com os
435 * pais para a árvore não se partir. O que não ganha raia (e o concluído há mais de 10 s) vira, por pai,
436 * "+N em execução" / "+N com falha" / "+N prontos".
437 */
438export function arvore(m: Modelo): Linha[] {
439 const { lista, filhos } = indice(m)
440 const ord = ordinais(lista)
441 const vistos = new Set(m.vistos)
442 const porId = new Map(lista.map(t => [t.id, t]))
443 const falhaNova = (t: Trabalhador) => t.situacao === 'falhou' && importante(t, m.agora) && !vistos.has(t.id)
444 const proprio = (t: Trabalhador) => rodando(t) || falhaNova(t) || m.agora - (t.fim ?? t.ultimoSinal) <= LIMITES.recolher
445 const visivel = (t: Trabalhador) => proprio(t) || desce(filhos, t, proprio) // visível ⇒ os pais também são
446 // Falha de comando curto conta como pronto; falha vista de trabalhador importante, como falha.
447 const bucket = (t: Trabalhador): 'feito' | 'rodando' | 'falhou' => (rodando(t) ? 'rodando' : t.situacao === 'falhou' && importante(t, m.agora) ? 'falhou' : 'feito')
448 const peso = (t: Trabalhador) =>
449 falhaNova(t) ? 0 : !rodando(t) ? 4 : ['agy', 'codex', 'comando', 'pergunta'].includes(t.tipo) ? 1 : t.tipo === 'agente-wf' ? 3 : 2
450
451 const escolhidos = new Set<string>()
452 for (const t of lista.filter(visivel).sort((a, b) => peso(a) - peso(b) || a.inicio - b.inicio)) {
453 const cadeia: Trabalhador[] = []
454 for (let x: Trabalhador | undefined = t; x && !escolhidos.has(x.id) && !cadeia.includes(x); x = porId.get(x.pai)) cadeia.push(x)
455 if (escolhidos.size + cadeia.length <= LIMITES.raias) cadeia.forEach(x => escolhidos.add(x.id))
456 }
457
458 const saida: Linha[] = []
459 const visitados = new Set<string>()
460 const grupo = (irmaos: Trabalhador[], nivel: number) => {
461 const quem = irmaos.filter(t => !visitados.has(t.id))
462 const chave = new Map(quem.map(t => [t.id, [desce(filhos, t, rodando) ? 0 : rodando(t) ? 1 : falhaNova(t) ? 2 : 3, t.inicio] as const]))
463 const mostrar = quem.filter(t => escolhidos.has(t.id)).sort((a, b) => chave.get(a.id)![0] - chave.get(b.id)![0] || chave.get(a.id)![1] - chave.get(b.id)![1])
464 for (const t of mostrar) {
465 visitados.add(t.id)
466 saida.push({ tipo: 'trabalhador', t, nivel, nome: nomeHumano(t), ...(ord.has(t.id) ? { ordinal: ord.get(t.id) } : {}) })
467 grupo(filhos.get(t.id) ?? [], nivel + 1)
468 }
469 const resto = quem.filter(t => !escolhidos.has(t.id))
470 for (const sit of ['rodando', 'falhou', 'feito'] as const) {
471 const ags = resto.filter(t => bucket(t) === sit)
472 if (ags.length) saida.push({ tipo: 'resumo', nivel, texto: textoResumo(sit, ags.length), situacao: sit, ids: ags.map(t => t.id) })
473 }
474 }
475 grupo(lista.filter(t => t.pai === 'principal' || !porId.has(t.pai)), 0)
476 return saida
477}
478hooks/saida.ts 101 lines1// Saída dos comandos, em funções PURAS: envoltório do Bash/PowerShell, cauda e porcentagem, passos do agy,
2// fases do journal do workflow. Quem lê os arquivos (com o $) é coleta.ts: o motor só segue o $ para funções
3// do próprio arquivo, nunca através de import.
4
5export const LIMITE = 3_000_000 // acima disto não lê: o $.fs não lê pedaço de arquivo
6const PCT = /(\d{1,3}(?:[.,]\d+)?)\s?%/g
7const NAO_PCT = /cpu|passed|desconto/i
8const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g
9
10const aspas = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
11
12/**
13 * Bash: todo comando (fundo inclusive) exporta AGY_EVENTS_DIR, para o agy chamado por qualquer script gravar
14 * os passos. Com `log` (só primeiro plano), o tee copia a saída para o registro; a saída continua indo ao Claude.
15 */
16export function envolverBash(cmd: string, agyDir: string, log?: string): string {
17 const env = `export AGY_EVENTS_DIR=${aspas(agyDir)}; export PYTHONUNBUFFERED=1;`
18 if (!log) return `${env}\n${cmd}`
19 const dir = log.slice(0, log.lastIndexOf('/'))
20 return `mkdir -p ${aspas(dir)} 2>/dev/null; ${env} {\n${cmd}\n} > >(tee ${aspas(log)}) 2>&1`
21}
22
23/** PowerShell: só as variáveis (sem tee). */
24export function envolverPowerShell(cmd: string, agyDir: string): string {
25 return `$env:AGY_EVENTS_DIR='${agyDir.replace(/'/g, "''")}'; $env:PYTHONUNBUFFERED='1'\n${cmd}`
26}
27
28/** Arquivo de saída de um comando em segundo plano, dito no texto do resultado. */
29export function arquivoDeSaida(texto: string): string | undefined {
30 return /Output is being written to: (\S.*?\.output)/.exec(texto)?.[1]?.replace(/\\/g, '/')
31}
32
33/** Porcentagem só de linha com cara de progresso ("63%", "[#### ] 63%", "63.0%|"); ignora CPU, testes, descontos. */
34export function porcentagem(linhas: string[]): number | undefined {
35 for (const l of linhas.slice(-3).reverse()) {
36 if (NAO_PCT.test(l)) continue
37 const achados = [...l.matchAll(PCT)].map(m => parseFloat(m[1]!.replace(',', '.'))).filter(n => n <= 100)
38 if (achados.length) return achados[achados.length - 1]
39 }
40 return undefined
41}
42
43/** Últimas 12 linhas (sem cores ANSI) e a porcentagem do texto de um registro. */
44export function caudaDe(texto: string): { cauda: string[]; pct?: number } {
45 const linhas = texto.slice(-6000).split(/\r|\n/).map(l => l.replace(ANSI, '').trimEnd()).filter(l => l.trim() !== '')
46 const pct = porcentagem(linhas)
47 return { cauda: linhas.slice(-12), ...(pct !== undefined ? { pct } : {}) }
48}
49
50export type PassosAgy = { modelo: string; passos: number; agora: string; fim: boolean; ok: boolean; tokens: number }
51
52/** Tokens do agy: cada passo informa `usage.total_tokens`; no fim vem um passo com o total acumulado
53 * (igual à soma dos anteriores, medido em 08/10). Esse não soma de novo. */
54export function somaTokensAgy(totais: number[]): number {
55 const ant = totais.slice(0, -1).reduce((s, n) => s + n, 0)
56 const ult = totais[totais.length - 1] ?? 0
57 return totais.length > 1 && ult === ant ? ult : ant + ult
58}
59
60function primeiroParametro(p: any): string {
61 if (!p || typeof p !== 'object') return ''
62 const v = p.CommandLine ?? p.command ?? p.AbsolutePath ?? p.path ?? p.Query ?? p.query ?? Object.values(p)[0]
63 return String(v ?? '').replace(/\s+/g, ' ').slice(0, 120)
64}
65
66/** Um arquivo de eventos do agy (stream-json, um por chamada): modelo, passos, passo atual ("view_file: x"), fim. */
67export function lerEventosAgy(texto: string): PassosAgy {
68 const a: PassosAgy = { modelo: '', passos: 0, agora: 'começando', fim: false, ok: false, tokens: 0 }
69 const totais: number[] = []
70 for (const linha of texto.split('\n')) {
71 let ev: any
72 try { ev = JSON.parse(linha) } catch { continue }
73 if (ev.event === 'init') a.modelo = String(ev.init?.model ?? '')
74 if (ev.event === 'step_update') {
75 const u = ev.step_update ?? {}
76 a.passos = Math.max(a.passos, Number(u.step_index ?? 0) + 1)
77 if (u.usage?.total_tokens) totais.push(Number(u.usage.total_tokens))
78 if (u.step_type === 'tool') a.agora = `${u.tool_name}: ${primeiroParametro(u.tool_info?.parameters)}`
79 else if (u.step_type === 'agent_response') a.agora = u.state === 'ACTIVE' ? 'escrevendo a resposta' : 'pensando'
80 }
81 if (ev.event === 'result') {
82 a.fim = true
83 a.ok = !/error/i.test(String(ev.result?.status ?? ''))
84 a.agora = 'concluído'
85 }
86 }
87 a.tokens = somaTokensAgy(totais)
88 return a
89}
90
91/** Fase de cada agente de um workflow, pelas linhas `started` do journal.jsonl. */
92export function fasesDoJornal(texto: string): Record<string, string> {
93 const fases: Record<string, string> = {}
94 for (const linha of texto.split('\n')) {
95 let d: any
96 try { d = JSON.parse(linha) } catch { continue }
97 if (d.type === 'started' && d.agentId && d.phase) fases[String(d.agentId)] = String(d.phase)
98 }
99 return fases
100}
101hooks/sessoes.ts 32 lines1// Outras sessões, em funções PURAS. Cada sessão publica o próprio resumo no $.store numa chave `sessao:<id>`
2// e lê as das outras (o $.store lista as chaves, então não precisa de índice; cada uma só escreve a sua).
3// As chamadas ao $.store ficam em coleta.ts (o motor só segue o $ dentro do próprio arquivo).
4
5import type { ResumoSessao } from '../types'
6
7const PREFIXO = 'sessao:'
8export const FRESCA = 120_000 // sem publicar há mais que isto: não aparece
9const ABANDONADA = 86_400_000 // chave de sessão que fechou sem avisar: apagada depois de um dia
10
11export const chaveSessao = (id: string) => PREFIXO + id
12export const ehSessao = (chave: string) => chave.startsWith(PREFIXO)
13
14/** Das entradas `sessao:*` do $.store (sem a própria): resumos frescos, em ordem fixa, e chaves para apagar. */
15export function separar(entradas: [string, unknown][], agora: number): { outras: ResumoSessao[]; apagar: string[] } {
16 const outras: ResumoSessao[] = []
17 const apagar: string[] = []
18 for (const [k, v] of entradas) {
19 const r = v as ResumoSessao | undefined
20 if (!r || typeof r.t !== 'number') continue
21 if (agora - r.t > ABANDONADA) apagar.push(k)
22 else if (agora - r.t <= FRESCA) outras.push(r)
23 }
24 return { outras: outras.sort((a, b) => (a.id < b.id ? -1 : 1)), apagar } // ordem fixa: a lista não pula
25}
26
27/** Precisa regravar a lista? Se mudou algo além da hora, ou se a hora guardada envelheceu (frescor no desenho). */
28export function sessoesMudaram(novas: ResumoSessao[], atuais: ResumoSessao[], agora: number): boolean {
29 const sem = (l: ResumoSessao[]) => JSON.stringify(l.map(s => ({ ...s, t: 0 })))
30 return sem(novas) !== sem(atuais) || atuais.some(s => agora - s.t > 60_000)
31}
32hooks/svg.ts 162 lines1import type { Acao, Trabalhador } from '../types'
2import { LIMITES, calado, tempo } from './regras'
3
4// Desenhos parados em SVG (o motor recria o SVG a cada redesenho: nada de animação, hover ou <title>).
5// Tudo puro: recebe dados, devolve a marcação.
6
7// Paleta pastel (SPEC): mesmas cores de sempre, dessaturadas, para cansar menos a vista.
8export const PALETA = {
9 fundo: '#1f1f1e',
10 cartao: '#262624',
11 borda: '#3a3936',
12 texto: '#e6e4df',
13 secundario: '#a8a59e',
14 apagado: '#77746e',
15 ok: '#9cc5a1',
16 aviso: '#d9bf8c',
17 avisoFundo: '#2a2620',
18 avisoBorda: '#574c36',
19 pendencia: '#d9a0a0',
20 pendenciaFundo: '#2b2322',
21 pendenciaBorda: '#5e4644',
22 agentes: '#9db4d9',
23 gemini: '#c9a8d0',
24 codex: '#93c4c4',
25 comando: '#a3a3a3',
26}
27const P = PALETA
28const FONTE = 'Segoe UI,system-ui,sans-serif'
29
30// Texto dentro do SVG: escapa o que quebraria a marcação.
31export const esc = (s: unknown) =>
32 String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
33
34const n1 = (v: number) => +v.toFixed(1)
35const svg = (w: number, h: number, rotulo: string, corpo: string) =>
36 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" role="img" aria-label="${esc(rotulo)}">${corpo}</svg>`
37const texto = (x: number, y: number, s: string, cor: string, tam: number, extra = '') =>
38 `<text x="${n1(x)}" y="${n1(y)}" font-size="${tam}" fill="${cor}" font-family="${FONTE}"${extra}>${esc(s)}</text>`
39const cortar = (s: string, n: number) => (s.length > n ? s.slice(0, Math.max(1, n - 1)) + '…' : s)
40// Silêncio em frase ("sem sinal há 6 min"): acima de 1 min, só minutos inteiros.
41export const semSinal = (ms: number) => tempo(ms < 60_000 ? ms : Math.floor(ms / 60_000) * 60_000)
42
43// Raia de um trabalhador na janela [agora - janelaMs, agora]: barra até o fim (ou até agora),
44// um ponto por ação, trecho hachurado âmbar onde há silêncio acima do limite, e um rótulo curto à direita.
45export function faixa(t: Trabalhador, agora: number, janelaMs: number, larguraPx: number, cor: string): string {
46 const W = Math.max(60, Math.round(larguraPx))
47 const H = 30
48 const y = 20
49 const x0 = 4
50 const x1 = W - 8
51 const ini = agora - janelaMs
52 const x = (ms: number) => n1(x0 + Math.min(1, Math.max(0, (ms - ini) / janelaMs)) * (x1 - x0))
53 const rodando = t.situacao === 'rodando'
54 const silencio = rodando ? calado(t, agora) : 0
55 const travou = silencio > LIMITES.calado
56 const a = x(t.inicio)
57 const b = Math.max(x(travou ? agora - silencio : t.fim ?? agora), a + 3)
58 const partes = [
59 `<line x1="${x0}" y1="${y + 0.5}" x2="${x1}" y2="${y + 0.5}" stroke="${P.borda}"/>`,
60 `<rect x="${a}" y="${y - 4}" width="${n1(b - a)}" height="9" rx="4.5" fill="${cor}" fill-opacity="${rodando ? 0.85 : 0.4}"/>`,
61 ]
62 if (travou && x1 - b > 1) {
63 partes.unshift(
64 `<defs><pattern id="h" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="5" height="5" fill="${P.avisoFundo}"/><line x1="0" y1="0" x2="0" y2="5" stroke="${P.aviso}" stroke-width="2"/></pattern></defs>`,
65 )
66 partes.push(`<rect x="${b}" y="${y - 5}" width="${n1(x1 - b)}" height="11" rx="2" fill="url(#h)" stroke="${P.aviso}" stroke-width="1" stroke-dasharray="3 2"/>`)
67 }
68 for (const ac of t.acoes ?? []) {
69 if (ac.t < ini) continue
70 partes.push(`<circle cx="${x(ac.t)}" cy="${y + 0.5}" r="1.9" fill="${ac.erro ? P.pendencia : '#f2f0ea'}" fill-opacity="0.8"/>`)
71 }
72 // Marca do fim: viva em "agora"; concluída na cor do resultado.
73 const marca = rodando ? (travou ? P.aviso : cor) : t.situacao === 'falhou' ? P.pendencia : t.situacao === 'feito' ? P.ok : P.apagado
74 partes.push(`<circle cx="${rodando ? x1 : b}" cy="${y + 0.5}" r="4" fill="${marca}" stroke="${P.fundo}" stroke-width="1.5"/>`)
75 const [rotulo, corRotulo] = travou
76 ? [`sem sinal há ${semSinal(silencio)}`, P.aviso]
77 : rodando && t.pct !== undefined
78 ? [`${Math.round(t.pct)}%`, P.texto]
79 : rodando && t.tipo === 'agy' && t.passos
80 ? [`passo ${t.passos}`, cor]
81 : t.situacao === 'falhou'
82 ? ['falhou', P.pendencia]
83 : ['', '']
84 if (rotulo) partes.push(texto(x1, 10, rotulo, corRotulo, 10.5, ' text-anchor="end" font-weight="600"'))
85 return svg(W, H, t.rotulo || t.id, partes.join(''))
86}
87
88// Altura e cor de cada tipo de ação na fita (mais alto = mais consequente).
89const TIPO: Record<Acao['tipo'], [number, string]> = {
90 leitura: [10, P.apagado],
91 busca: [14, P.agentes],
92 web: [14, P.codex],
93 outro: [10, P.borda],
94 agente: [19, P.gemini],
95 comando: [19, P.comando],
96 edicao: [24, P.texto],
97}
98
99// Fita das ações do principal: uma barrinha por ação, da mais antiga (até 40 min) até agora. Sem legenda:
100// a altura já diz o peso (edição > comando > busca > leitura).
101export function fita(acoes: Acao[], agora: number, larguraPx: number): string {
102 const W = Math.max(120, Math.round(larguraPx))
103 const H = 44
104 const base = 30
105 const recentes = acoes.filter(a => a.t <= agora && a.t >= agora - 40 * 60_000)
106 const desde = recentes.length ? Math.min(...recentes.map(a => a.t)) : agora
107 const janela = Math.min(40 * 60_000, Math.max(2 * 60_000, (agora - desde) * 1.08))
108 const ini = agora - janela
109 const partes = [`<line x1="0" y1="${base + 0.5}" x2="${W}" y2="${base + 0.5}" stroke="${P.borda}"/>`]
110 for (const a of recentes) {
111 const [alt, cor] = TIPO[a.tipo] ?? TIPO.outro
112 const x = n1(((a.t - ini) / janela) * (W - 4))
113 partes.push(`<rect x="${x}" y="${base - alt}" width="3.4" height="${alt}" rx="1" fill="${a.erro ? P.pendencia : cor}"/>`)
114 }
115 partes.push(texto(0, 42, `há ${tempo(janela).replace(/\s?\d+s$/, '')}`, P.apagado, 10), texto(W, 42, 'agora', P.apagado, 10, ' text-anchor="end"'))
116 return svg(W, H, `Fita das últimas ${recentes.length} ações`, partes.join(''))
117}
118
119// Barra fina de uso (cota): trilho e parte cheia.
120export function barraUso(pct: number, larguraPx: number, cor: string): string {
121 const W = Math.max(20, Math.round(larguraPx))
122 const cheio = n1((Math.min(100, Math.max(0, pct)) / 100) * W)
123 return svg(
124 W,
125 6,
126 `${Math.round(pct)}% usado`,
127 `<rect x="0" y="1" width="${W}" height="4" rx="2" fill="${P.borda}"/><rect x="0" y="1" width="${cheio}" height="4" rx="2" fill="${cor}"/>`,
128 )
129}
130
131// Quebra em até `max` linhas de `n` caracteres; a última leva o resto, cortado com reticências.
132function quebrar(s: string, n: number, max: number): string[] {
133 const linhas: string[] = []
134 let atual = ''
135 for (const p of s.split(/\s+/).filter(Boolean)) {
136 if (!atual) atual = p
137 else if (atual.length + 1 + p.length <= n) atual += ' ' + p
138 else {
139 linhas.push(atual)
140 atual = p
141 }
142 }
143 if (atual) linhas.push(atual)
144 if (linhas.length <= max) return linhas.map(l => cortar(l, n))
145 return [...linhas.slice(0, max - 1), cortar(linhas.slice(max - 1).join(' '), n)]
146}
147
148// Manchete grande (Text não tem tamanho de fonte; SVG tem) e a linha de baixo, menor.
149export function titulo(textoGrande: string, sub: string, corTexto: string, corSub: string, larguraPx: number): string {
150 const W = Math.max(120, Math.round(larguraPx))
151 const F = 16
152 const f = 12.5
153 const linhas = quebrar(textoGrande, Math.max(8, Math.floor((W - 4) / (F * 0.56))), 2)
154 const partes = linhas.map((l, i) => texto(0, F + i * (F + 5), l, corTexto, F, ' font-weight="600"'))
155 let H = linhas.length * (F + 5) + 2
156 if (sub) {
157 H += f + 4
158 partes.push(texto(0, H - 4, cortar(sub, Math.floor((W - 4) / (f * 0.52))), corSub, f, ' font-weight="600"'))
159 }
160 return svg(W, Math.ceil(H), `${textoGrande}. ${sub}`, partes.join(''))
161}
162hooks/relogio.tsx 37 lines1import type { ClientModule } from 'claude-code'
2
3// Tempo corrente que conta sozinho a cada segundo, mesmo sem redesenho do painel.
4// props: desde = início (ms); base = o "agora" do último desenho do painel; cor opcional.
5// Conta a partir de `base - desde` e recomeça quando o painel manda uma base nova.
6type Props = { desde: number; base: number; cor?: string }
7type Estado = { base: number; n: number }
8
9// Cópia de regras.tempo ("45s", "7min 47s", "12 min", "1h 05min"): o módulo de superfície fica sozinho.
10function tempo(ms: number): string {
11 const s = Math.max(0, Math.round(ms / 1000))
12 if (s < 60) return `${s}s`
13 const m = Math.floor(s / 60)
14 if (m < 10) return s % 60 ? `${m}min ${s % 60}s` : `${m} min`
15 if (m < 60) return `${m} min`
16 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}min`
17}
18
19const Relogio: ClientModule<Props, Estado> = (p, s) => {
20 const { Text } = s.elements
21 if (s.state === undefined) {
22 // Um único laço por instância, iniciado no primeiro desenho.
23 s.setState({ base: p.base, n: 0 })
24 s.every(1000, () => {
25 const st = s.state
26 if (st) s.setState({ base: st.base, n: st.n + 1 })
27 })
28 } else if (s.state.base !== p.base) {
29 // Props novas (o painel redesenhou): só aqui, nunca em todo desenho.
30 s.setState({ base: p.base, n: 0 })
31 }
32 const n = s.state?.base === p.base ? s.state.n : 0
33 return <Text {...(p.cor ? { color: p.cor } : { dimColor: true })}>{tempo(p.base - p.desde + n * 1000)}</Text>
34}
35
36export default Relogio
37types/index.d.ts 156 lines1// Contrato do painel (v2). Coleta grava; regras derivam; desenho lê. Ver SPEC.md.
2
3/** Tipo do trabalhador: define cor, nome humano e regras de silêncio. */
4export type Tipo = 'subagente' | 'workflow' | 'agente-wf' | 'comando' | 'agy' | 'codex' | 'pergunta'
5
6export type Situacao = 'rodando' | 'feito' | 'falhou' | 'parado'
7
8/** Um ponto na raia: uma ação vista. */
9export type Acao = { t: number; tipo: 'leitura' | 'busca' | 'edicao' | 'comando' | 'web' | 'agente' | 'outro'; texto: string; erro?: boolean }
10
11export type Trabalhador = {
12 id: string // tool_use_id da chamada; agente de workflow: `ag:<agentId>`
13 tipo: Tipo
14 pai: string // id de quem chamou; 'principal' para o agente principal
15 rotulo: string // nome técnico: rótulo do workflow, subagentType, descrição do comando
16 fase?: string // fase do workflow (agente-wf)
17 tarefa?: string // o que pediram (prompt ou descrição, até 300 caracteres)
18 agora: string // o que faz agora, em português simples
19 inicio: number
20 fim?: number
21 situacao: Situacao
22 ultimoSinal: number // última atividade vista (ferramenta, trecho do modelo, saída, passo do agy)
23 acoes: Acao[] // últimas 60
24 agentId?: string // subagente/agente-wf: o agentId do motor
25 modelo?: string
26 tokensNovos?: number // entrada + saída + criação de cache
27 tokensCache?: number // releitura de cache (barata)
28 pct?: number // porcentagem real informada pela saída do comando
29 passos?: number // agy: passos dados
30 plano?: Plano // checklist declarado (TodoWrite/TaskCreate/TaskUpdate) por este agente
31 emFundo?: boolean
32 tarefaFundo?: string // id da tarefa em segundo plano (para fechar no aviso de término)
33 log?: string // arquivo com a saída do comando
34 agyDir?: string // pasta onde o agy grava os passos (AGY_EVENTS_DIR)
35 cauda?: string[] // últimas linhas da saída
36 explicacao?: string
37 explicando?: boolean
38}
39
40export type Plano = { feitos: number; total: number; atual?: string }
41
42/** Sinal que exige ação do usuário e não deriva só do tempo. */
43export type Pendente = {
44 id: string // tool_use_id
45 tipo: 'permissao' | 'pergunta' | 'plano' | 'falha-api'
46 titulo: string // "Permissão para rodar: apagar a pasta build"
47 detalhe?: string
48 quem: string // id do trabalhador (ou 'principal')
49 desde: number
50}
51
52/** Item mostrado em Pendências (nivel 'pendencia') ou Avisos (nivel 'aviso'). Derivado em regras.ts. */
53export type Atencao = {
54 id: string
55 nivel: 'pendencia' | 'aviso'
56 tipo: 'permissao' | 'pergunta' | 'plano' | 'falha-api' | 'erro' | 'travado' | 'calado' | 'contexto' | 'cota' | 'sessao'
57 titulo: string
58 detalhe?: string
59 quem?: string
60 desde: number
61 acoes: ('explicar' | 'dispensar' | 'parar' | 'esperar')[]
62}
63
64export type Workflow = { id: string; nome: string; desc: string; fases: { titulo: string; detalhe?: string }[]; dir?: string; fasePorAgente: Record<string, string>; runId?: string /* acrescentado (coleta): liga agent.spawn sem tool_use_id ao workflow */ }
65
66export type Uso = {
67 contextoPct?: number
68 cota5h?: number
69 renova5h?: string // ISO
70 semana?: number
71 renovaSemana?: string // ISO
72}
73
74export type Principal = {
75 ocupado: boolean // turno do agente principal em curso
76 ultimoSinal: number
77 fimTurno?: number
78 plano?: Plano
79 tokensNovos: number
80 tokensCache: number
81 acoes: Acao[] // últimas 200 ações do principal (fita)
82 titulo?: string // assunto da sessão (para manchete e outras sessões)
83}
84
85/** Resumo que cada sessão publica em $.store para as outras verem. */
86export type ResumoSessao = {
87 id: string
88 nome: string
89 nivel: 'pendencia' | 'andando' | 'ocioso'
90 frentes: number
91 pendencias: string[] // títulos curtos
92 t: number // última publicação
93}
94
95/** Tudo que o desenho precisa, lido do estado num só objeto. */
96export type Modelo = {
97 agora: number
98 trabalhadores: Record<string, Trabalhador>
99 workflows: Record<string, Workflow>
100 pendentes: Record<string, Pendente>
101 vistos: string[]
102 uso: Uso
103 principal: Principal
104 sessoes: ResumoSessao[] // outras sessões (sem a atual), já filtradas por frescor
105 aberto: string
106 historicoAberto: boolean
107 sessoesAbertas: boolean
108 verGlossario: boolean
109 glossario: { termo: string; definicao: string }[]
110}
111
112/** Linha da seção "Em execução", já ordenada e recortada por regras.arvore(). */
113export type Linha =
114 | { tipo: 'trabalhador'; t: Trabalhador; nivel: number; nome: string; ordinal?: number }
115 | { tipo: 'resumo'; nivel: number; texto: string; situacao: 'feito' | 'rodando' | 'falhou'; ids: string[] }
116
117export type Veredito = {
118 nivel: 'pendencia' | 'aviso' | 'andando' | 'ocioso'
119 manchete: string
120 sub: string
121 tiles: { rotulo: string; valor: string; detalhe: string; nivel: 'ok' | 'aviso' | 'pendencia' | 'neutro' }[]
122}
123
124/**
125 * Ações dos botões, já presas ao $ por quem desenha (register.tsx). O motor recusa o módulo que passa
126 * o $ para função importada ("$ is followed only into a function declared in this same file"), então
127 * desenho.tsx recebe `desenharPainel($.ui.resolve(e), e, m, acoes)` em vez do $.
128 */
129export type Acoes = {
130 abrir: (id: string) => unknown
131 dispensar: (ids: string[]) => unknown
132 explicar: (id: string) => unknown
133 parar: (id: string) => unknown
134 alternar: (chave: 'historicoAberto' | 'sessoesAbertas' | 'verGlossario') => unknown
135}
136
137declare module 'claude-code' {
138 interface PluginState {
139 'painel-vivo': {
140 trabalhadores: Record<string, Trabalhador>
141 workflows: Record<string, Workflow>
142 pendentes: Record<string, Pendente>
143 vistos: string[]
144 uso: Uso
145 principal: Principal
146 sessoes: ResumoSessao[]
147 relogio: number
148 aberto: string
149 historicoAberto: boolean
150 sessoesAbertas: boolean
151 verGlossario: boolean
152 glossario: { termo: string; definicao: string }[]
153 }
154 }
155}
156