SLOPSHOPPER

painel-vivo

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…

newpaneguardcommandpromptmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · painel-vivo
│ ┃ Painel ✕ › fix the failing auth test and add an audit log call │ ┃ ocioso desde 08:53 Glossário │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Read 6 lines │ ┃ │ Concluído às 08:53 │ ⏺ Update(src/auth.ts) │ ┃ │ 4 comandos · 0s │ ⎿ Added 2 lines, removed 1 line │ ┃ ╰──────────────────────────────────────────╯ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ▸ Histórico · 9 ações │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Uso │ ┃ 5h 31% · renova às 09:53 ✻ Worked for 42s · done 4:20 PM │ │ › /painel │ ⎿ painel-vivo: Painel aberto. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Painel
ocioso desde 08:53 Glossário ╭──────────────────────────────────────────────────────────╮ │ Concluído às 08:53 │ │ 4 comandos · 0s │ ╰──────────────────────────────────────────────────────────╯ ▸ Histórico · 9 ações Uso 5h 31% · renova às 09:53
README

claude-mods

Mods do Claude Code (function hooks) que uso todo dia. · Claude Code mods (function hooks) I use every day.

Português · English


Português

Seis mods, cada um resolvendo um problema que apareceu no uso real. A interface e as mensagens dos mods estão em português.

Instalar

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

Os mods

ModO que fazO que acessa
sonnet-por-padraoSubagente 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-provaQuando 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-passosDepois 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-vivoPainel 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".
lixeiraNega 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-pushAntes 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.

Por que o revisor-com-prova

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.

Requisitos e limites

  • 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".

Créditos

A leitura de comandos da lixeira (aspas, sudo/env, alvos seguros) veio de ideias do launch-codes, da OneWave AI (MIT).

Licença: MIT.


English

Six mods, each fixing a problem that came up in real use. The mods' UI and messages are in Portuguese.

Install

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

The mods

ModWhat it doesWhat 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.

Why revisor-com-prova

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.

Requirements and limits

  • 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".

Credits

The command parsing in lixeira (quotes, sudo/env, safe targets) builds on ideas from launch-codes by OneWave AI (MIT).

License: MIT.

Source 9 files
hooks/register.tsx 9 lines
1import 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)
9
hooks/coleta.ts 1080 lines
1// 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}
1080
hooks/desenho.tsx 346 lines
1import 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}
346
hooks/regras.ts 478 lines
1// 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}
478
hooks/saida.ts 101 lines
1// 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}
101
hooks/sessoes.ts 32 lines
1// 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}
32
hooks/svg.ts 162 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
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}
162
hooks/relogio.tsx 37 lines
1import 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
37
types/index.d.ts 156 lines
1// 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