Acumula as decisões que só quem usa a sessão pode tomar, deixa responder com botões e leva às outras sessões no cmux que esperam uma decisão.

Um mod do Claude Code: um plugin de hooks em função (hooks/register.tsx), com faixa acima do prompt e painel próprio. Ele junta, em cada sessão, as decisões que só o dono da sessão pode tomar, deixa respondê-las por clique e leva às outras sessões abertas no cmux que também esperam uma decisão.
mcp__decisoes__registrar_decisao (pergunta, contexto, até 9 opções, a recomendada e se ele parou esperando) e uma instrução no prompt de sistema para usá-la em vez de só perguntar no texto: merge, deploy, escopo, prazo, aval, escolha entre caminhos de custo ou risco diferentes./decisoes) abre o painel, uma decisão por vez, com as opções numeradas, resposta livre, "adiar" e "fechar". Quando a última pendente é respondida, as respostas vão juntas ao Claude como um turno.claude --resume) numa aba nova do mesmo workspace. Fora do cmux, copia o comando de retomada.POUSO CONCLUÍDO.hooks/hooks.json com modules). Validado e testado na 2.1.289./plugin marketplace add fernando-bertholdo/4-successful-AI-life
/plugin install decisoes@4-successful-ai-life
/reload-plugins
Os textos que o agente lê chamam quem decide de "o usuário". A opção dono troca isso pelo nome, com o artigo, para as frases ficarem certas: "o Fernando", "a Ana". Ela aparece no /config, entre as opções do plugin.
Os textos do mod estão em português.
claude plugin validate plugins/decisoes
claude plugin test plugins/decisoes
Os tipos (.claude-plugin/types/) são escritos pelo próprio Claude Code quando o mod carrega e não são versionados; depois disso, tsc -p plugins/decisoes confere o mod contra eles.
hooks/register.tsx 673 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Decision, Landing, OtherSession, Registration } from '../types'
5
6// ── Constantes ──────────────────────────────────────────────────────────────
7
8const PANE = 'decisoes'
9const TOOL = 'registrar_decisao'
10const TOOL_FULL = 'mcp__decisoes__registrar_decisao'
11/** Uma sessão que não atualizou o cadastro nesse intervalo é tratada como encerrada */
12const ALIVE_MS = 90_000
13const HEARTBEAT_MS = 30_000
14/** Cadastros mais velhos que isso somem da lista de outras sessões */
15const STALE_MS = 14 * 24 * 60 * 60 * 1000
16/** Onde procurar o CLI do Cmux, nesta ordem */
17const CMUX_CANDIDATES = ['cmux', '/Applications/cmux.app/Contents/Resources/bin/cmux']
18
19/** Linhas do texto do agente que indicam uma decisão de quem usa a sessão */
20const TEXT_MARKERS = /🔴|aguardo (o )?seu aval|preciso da sua decis[aã]o|depende de voc[eê] decidir/i
21
22/** Como os textos chamam quem decide, com o artigo; a opção `dono` do plugin troca */
23const DONO_PADRAO = 'o usuário'
24
25/** "o usuário" → "O usuário", para o começo de frase */
26const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
27
28const instructions = (dono: string) => [
29 `Decisões que esperam ${dono}. Quando surgir algo que só ${dono} pode decidir`,
30 '(aprovar merge ou deploy, mudar escopo, aceitar ou adiar um achado de revisão,',
31 'definir prazo, dar aval a uma seção ou proposta, escolher entre caminhos com custo',
32 'ou risco diferentes), chame a ferramenta mcp__decisoes__registrar_decisao em vez de',
33 'só perguntar no texto. Dê opções curtas e mutuamente exclusivas (até 9), indique a',
34 'recomendada e diga em `bloqueia` se você precisa parar até a resposta. Depois de',
35 'registrar, siga com o que não depende da decisão ou pouse; não repita a pergunta',
36 'no texto. Não registre o que você mesmo pode decidir com segurança. As respostas',
37 'chegam depois numa mensagem do plugin decisoes.',
38].join(' ')
39
40// ── Estado (redesenha quem lê) ──────────────────────────────────────────────
41
42const decisions = atom({ plugin: 'decisoes', key: 'decisions' } as const, [] as Decision[])
43const others = atom({ plugin: 'decisoes', key: 'others' } as const, [] as OtherSession[])
44const nowAtom = atom({ plugin: 'decisoes', key: 'now' } as const, 0)
45const landing = atom({ plugin: 'decisoes', key: 'landing' } as const, null as Landing | null)
46
47const LANDED = /POUSO (DE EMERGÊNCIA )?CONCLUÍDO/
48
49const landingText = (dono: string) => ({
50 organizado: [
51 `Pouso organizado: ${dono} vai fechar a máquina em breve (alguns minutos).`,
52 'Não comece frente nova. Termine o passo atual até um ponto consistente, sem deixar arquivo pela metade.',
53 'Depois: (1) guarde o trabalho em andamento do jeito que este projeto prefere (commit local de WIP ou stash nomeado; sem push forçado);',
54 '(2) encerre os processos e monitores que você iniciou, ou diga quais continuam;',
55 '(3) registre o estado onde este projeto guarda isso (issue, nota de sessão ou .planning): o que foi feito, o que falta, próximos passos e decisões pendentes, e gere um prompt de retomada;',
56 "(4) responda com um resumo de até 5 linhas terminando em 'POUSO CONCLUÍDO'.",
57 ].join(' '),
58 emergencia: [
59 `Pouso de emergência: ${dono} vai fechar a máquina agora (1 a 2 minutos).`,
60 'Pare assim que for seguro, sem deixar arquivo corrompido. Não rode testes, builds nem nada demorado.',
61 'Faça só: guarde o trabalho em andamento (commit local de WIP ou stash nomeado, sem push);',
62 'escreva em até 5 linhas onde parou e o próximo passo, num arquivo ou comentário de retomada do projeto;',
63 "responda terminando em 'POUSO DE EMERGÊNCIA CONCLUÍDO'.",
64 ].join(' '),
65})
66
67type $T = EngineInterface
68
69// ── Utilidades ──────────────────────────────────────────────────────────────
70
71const isPending = (d: Decision) => d.estado === 'pendente' || d.estado === 'adiada'
72
73/** Pendentes primeiro, adiadas depois, cada grupo pela ordem de chegada */
74const queue = (list: Decision[]) =>
75 list
76 .filter(isPending)
77 .sort((a, b) => (a.estado === b.estado ? a.seq - b.seq : a.estado === 'pendente' ? -1 : 1))
78
79function ago(ms: number): string {
80 const m = Math.max(0, Math.floor(ms / 60_000))
81 if (m < 1) return 'agora'
82 if (m < 60) return `${m}min`
83 const h = Math.floor(m / 60)
84 if (h < 24) return `${h}h${String(m % 60).padStart(2, '0')}`
85 return `${Math.floor(h / 24)}d`
86}
87
88const shorten = (text: string, max: number) =>
89 text.length <= max ? text : text.slice(0, max - 1).trimEnd() + '…'
90
91const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
92
93/** Tira marcadores e ênfase markdown de uma linha detectada no texto */
94const cleanLine = (line: string) =>
95 line
96 .replace(/🔴/g, '')
97 .replace(/[*_`]+/g, '')
98 .replace(/^[\s\-•>#]+/, '')
99 .trim()
100
101/** O que `cmux tree --all --json --id-format both` diz de uma aba: onde ela está e como se chama */
102type Located = { windowId?: string; workspaceId: string; workspaceTitle?: string; surfaceRef?: string; surfaceTitle?: string }
103
104const upper = (v: unknown) => (typeof v === 'string' ? v.toUpperCase() : '')
105
106/** Título de aba sem o ✳ que o Claude Code põe no título do terminal */
107const tidyTitle = (t: unknown) => (typeof t === 'string' ? t.replace(/^\s*✳\s*/, '').trim() || undefined : undefined)
108
109/** Acha a aba `surfaceId` (UUID) na árvore de todas as janelas do Cmux */
110function locate(tree: unknown, surfaceId: string): Located | undefined {
111 const want = surfaceId.toUpperCase()
112 const t = tree as { windows?: { id?: string; workspaces?: Record<string, unknown>[] }[] } | undefined
113 for (const win of t?.windows ?? []) {
114 for (const ws of win.workspaces ?? []) {
115 const panes = (ws.panes as { surfaces?: Record<string, unknown>[] }[] | undefined) ?? []
116 for (const pane of panes) {
117 for (const sf of pane.surfaces ?? []) {
118 if (upper(sf.id) === want) {
119 return {
120 windowId: win.id,
121 workspaceId: String(ws.id),
122 workspaceTitle: tidyTitle(ws.title),
123 surfaceRef: typeof sf.ref === 'string' ? sf.ref : undefined,
124 surfaceTitle: tidyTitle(sf.title),
125 }
126 }
127 }
128 }
129 }
130 }
131 return undefined
132}
133
134/** O workspace `workspaceId` na árvore: a janela dele e o título */
135function locateWorkspace(tree: unknown, workspaceId: string) {
136 const want = workspaceId.toUpperCase()
137 const t = tree as { windows?: { id?: string; workspaces?: Record<string, unknown>[] }[] } | undefined
138 for (const win of t?.windows ?? []) {
139 for (const ws of win.workspaces ?? []) {
140 if (upper(ws.id) === want) return { windowId: win.id, title: tidyTitle(ws.title) }
141 }
142 }
143 return undefined
144}
145
146function parseJson(text: string): unknown {
147 try {
148 return JSON.parse(text)
149 } catch {
150 return undefined
151 }
152}
153
154// ── O mod ───────────────────────────────────────────────────────────────────
155
156// Valores do módulo: refeitos a cada recarga em session.start (o estado que importa fica em $.state e $.store)
157let sessionId = ''
158let cmuxPath: string | null | undefined // undefined: ainda não procurado; null: não achado
159let registeredThisTurn = false
160let turnRunning = false
161let namesFetchedAt = 0
162/** Quem decide, como os textos o chamam; vem da opção `dono` em cada carga */
163let dono = DONO_PADRAO
164let cachedNames: { workspaceName?: string; tabTitle?: string; windowId?: string } = {}
165
166// ── Cmux ────────────────────────────────────────────────────────────────
167
168async function cmux($: $T, args: string[]) {
169 const candidates = cmuxPath ? [cmuxPath] : cmuxPath === null ? [] : CMUX_CANDIDATES
170 for (const bin of candidates) {
171 try {
172 // CMUX_QUIET cala os avisos de alias que o CLI escreve em stderr
173 const r = await $.process.run([bin, ...args], { timeoutMs: 10_000, env: { CMUX_QUIET: '1' } })
174 if (r.exitCode === 127) continue
175 cmuxPath = bin
176 return r
177 } catch {
178 // binário não encontrado: tenta o próximo
179 }
180 }
181 if (cmuxPath === undefined) cmuxPath = null
182 return { exitCode: -1, stdout: '', stderr: 'cmux não encontrado' }
183}
184
185/** A árvore de todas as janelas; `list-workspaces` só mostra a janela de quem chama */
186async function cmuxTree($: $T) {
187 const r = await cmux($, ['tree', '--all', '--json', '--id-format', 'both'])
188 return { exitCode: r.exitCode, tree: r.exitCode === 0 ? parseJson(r.stdout) : undefined }
189}
190
191async function fetchNames($: $T, surfaceId: string) {
192 const { tree } = await cmuxTree($)
193 const here = tree ? locate(tree, surfaceId) : undefined
194 if (here) cachedNames = { workspaceName: here.workspaceTitle, tabTitle: here.surfaceTitle, windowId: here.windowId }
195}
196
197// ── Persistência ────────────────────────────────────────────────────────
198
199async function loadDecisions($: $T) {
200 const saved = (await $.store.get(`dec:${sessionId}`)) as Decision[] | undefined
201 await update($, decisions, () => (Array.isArray(saved) ? saved : []))
202}
203
204async function saveDecisions($: $T, fn: (list: Decision[]) => Decision[]) {
205 await update($, decisions, fn)
206 const list = await read($, decisions)
207 // Enviadas há mais de um dia não precisam ficar guardadas
208 const now = await $.clock.now()
209 const kept = list.filter(d => d.estado !== 'enviada' || now - (d.respondidaEm ?? now) < 86_400_000)
210 await $.store.set(`dec:${sessionId}`, kept)
211 heartbeat($).catch(() => {})
212}
213
214/** Grava o cadastro desta sessão e relê o das outras */
215async function heartbeat($: $T) {
216 const id = await $.session.id()
217 if (id !== sessionId) {
218 // /clear ou retomada trocou o id: a sessão anterior fica marcada como encerrada
219 if (sessionId) await markEnded($, sessionId)
220 sessionId = id
221 await loadDecisions($)
222 }
223 const now = await $.clock.now()
224 const workspaceId = await $.env.get('CMUX_WORKSPACE_ID')
225 const surfaceId = await $.env.get('CMUX_SURFACE_ID')
226 if (surfaceId && now - namesFetchedAt > 2 * 60_000) {
227 namesFetchedAt = now
228 await fetchNames($, surfaceId)
229 }
230 const pend = queue(await read($, decisions))
231 const reg: Registration = {
232 sessionId,
233 cwd: await $.session.cwd(),
234 workspaceId,
235 surfaceId,
236 windowId: cachedNames.windowId,
237 workspaceName: cachedNames.workspaceName,
238 tabTitle: cachedNames.tabTitle,
239 pending: pend.length,
240 oldestPendingAt: pend.length ? Math.min(...pend.map(d => d.criadaEm)) : undefined,
241 lastSeen: now,
242 }
243 await $.store.set(`reg:${sessionId}`, reg)
244
245 const list: OtherSession[] = []
246 for (const key of await $.store.keys()) {
247 if (!key.startsWith('reg:') || key === `reg:${sessionId}`) continue
248 const other = (await $.store.get(key)) as Registration | undefined
249 if (!other) continue
250 if (now - other.lastSeen > STALE_MS) {
251 await $.store.delete(key)
252 continue
253 }
254 if (other.pending <= 0) continue
255 const ws = other.workspaceName ?? 'sessão'
256 const tab = other.tabTitle ?? other.sessionId.slice(0, 8)
257 list.push({
258 sessionId: other.sessionId,
259 label: `${shorten(ws, 22)} › ${shorten(tab, 28)}`,
260 pending: other.pending,
261 isAlive: !other.ended && now - other.lastSeen < ALIVE_MS,
262 })
263 }
264 list.sort((a, b) => b.pending - a.pending)
265 await update($, others, () => list)
266 await update($, nowAtom, () => now)
267}
268
269async function markEnded($: $T, id: string) {
270 const reg = (await $.store.get(`reg:${id}`)) as Registration | undefined
271 if (reg) await $.store.set(`reg:${id}`, { ...reg, ended: true, lastSeen: await $.clock.now() })
272}
273
274// ── Decisões ────────────────────────────────────────────────────────────
275
276async function addDecision($: $T, d: Omit<Decision, 'id' | 'seq' | 'criadaEm' | 'estado'>) {
277 const now = await $.clock.now()
278 let id = ''
279 await saveDecisions($, list => {
280 const seq = list.reduce((m, x) => Math.max(m, x.seq), 0) + 1
281 id = `D-${seq}`
282 return [...list, { ...d, id, seq, criadaEm: now, estado: 'pendente' }]
283 })
284 $.ui.toast(`⚑ Nova decisão ${id}: ${shorten(d.pergunta, 60)}`)
285 return id
286}
287
288async function answer($: $T, id: string, resposta: string) {
289 if (!resposta) return
290 const now = await $.clock.now()
291 await saveDecisions($, list =>
292 list.map(d => (d.id === id ? { ...d, estado: 'respondida', resposta, respondidaEm: now } : d)),
293 )
294 // Respondida a última que estava na fila (adiadas não contam), envia o lote
295 const rest = (await read($, decisions)).filter(d => d.estado === 'pendente')
296 if (rest.length === 0) await sendBatch($)
297}
298
299async function postpone($: $T, id: string) {
300 await saveDecisions($, list => list.map(d => (d.id === id ? { ...d, estado: 'adiada' } : d)))
301}
302
303async function sendBatch($: $T) {
304 const answered = (await read($, decisions)).filter(d => d.estado === 'respondida')
305 if (answered.length === 0) return
306 const lines = answered.map(d => `- ${d.id} — ${d.pergunta}\n Resposta: ${d.resposta}`)
307 const text = [
308 `Respostas às decisões (${answered.length}):`,
309 ...lines,
310 '',
311 'Siga a partir daqui com base nessas respostas.',
312 ].join('\n')
313 // Entra como turno próprio assim que a sessão estiver ociosa
314 $.prompt.submit({ text }).catch(() => $.ui.toast('Não consegui enviar as decisões ao Claude.'))
315 const ids = new Set(answered.map(d => d.id))
316 await saveDecisions($, list => list.map(d => (ids.has(d.id) ? { ...d, estado: 'enviada' } : d)))
317 $.ui.toast(`✓ ${answered.length} ${answered.length === 1 ? 'decisão enviada' : 'decisões enviadas'} ao Claude`)
318 await $.ui.close({ id: PANE })
319}
320
321async function openPane($: $T) {
322 await $.ui.open({ id: PANE, title: 'Decisões', focus: true, closeOnEscape: true })
323}
324
325// ── Navegação para outra sessão ─────────────────────────────────────────
326
327async function requestLanding($: $T, kind: Landing['kind']) {
328 const text = landingText(dono)[kind]
329 let injected = false
330 if (turnRunning) {
331 // Entra no turno em curso: o modelo lê no próximo passo, sem interromper a ferramenta que está rodando
332 try {
333 const r = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
334 injected = !('deny' in r && r.deny)
335 } catch {
336 injected = false
337 }
338 }
339 // Sessão parada, ou a injeção falhou: vira um turno próprio assim que a sessão ficar ociosa
340 if (!injected) $.prompt.submit({ text }).catch(() => $.ui.toast('Não consegui pedir o pouso ao Claude.'))
341 await update($, landing, () => ({ kind, startedAt: Date.now() }))
342 $.ui.toast(kind === 'emergencia' ? '⚠ Pouso de emergência pedido' : '🛬 Pouso organizado pedido')
343}
344
345async function goTo($: $T, targetId: string, label: string) {
346 const reg = (await $.store.get(`reg:${targetId}`)) as Registration | undefined
347 if (!reg) return $.ui.toast('Essa sessão não está mais no cadastro.')
348 const resume = `claude --resume ${reg.sessionId}`
349 const fallback = async (why: string) => {
350 const full = `cd ${shellQuote(reg.cwd)} && ${resume}`
351 const copied = await $.ui.copy({ text: full })
352 $.ui.toast(`${why} ${copied.isCopied ? 'Copiei o comando para retomar.' : `Retome com: ${full}`}`, { timeoutMs: 8000 })
353 }
354 if (!reg.workspaceId || !reg.surfaceId) return fallback('Essa sessão não foi aberta pelo Cmux.')
355
356 const { exitCode, tree } = await cmuxTree($)
357 if (exitCode === -1) return fallback('Não achei o CLI do Cmux.')
358 if (!tree) return fallback('O Cmux não respondeu à consulta das abas.')
359
360 const now = await $.clock.now()
361 const isAlive = !reg.ended && now - reg.lastSeen < ALIVE_MS
362 const tab = locate(tree, reg.surfaceId)
363
364 const focus = async (workspaceId: string, panel: string, windowId?: string) => {
365 const win = windowId ? ['--window', windowId] : []
366 await cmux($, ['select-workspace', '--workspace', workspaceId, ...win])
367 await cmux($, ['focus-panel', '--panel', panel, '--workspace', workspaceId, ...win])
368 }
369
370 if (isAlive && tab) return focus(tab.workspaceId, reg.surfaceId, tab.windowId)
371
372 const ws = locateWorkspace(tree, reg.workspaceId)
373 if (!ws) return fallback('O workspace dessa sessão não existe mais no Cmux.')
374 const where = ws.title ?? reg.workspaceName ?? 'o workspace de origem'
375 const question = tab
376 ? `A sessão de ${label} foi encerrada naquela aba. Retomar numa aba nova de ${where}?`
377 : `${label} não está mais aberta no Cmux. Retomar a sessão numa aba nova de ${where}?`
378 let choice = 'Cancelar'
379 try {
380 choice = await $.ui.ask(question, ['Abrir em aba nova', 'Cancelar'])
381 } catch {
382 return
383 }
384 if (choice !== 'Abrir em aba nova') return
385
386 const win = ws.windowId ? ['--window', ws.windowId] : []
387 const created = await cmux($, [
388 'new-surface',
389 '--workspace',
390 reg.workspaceId,
391 ...win,
392 '--working-directory',
393 reg.cwd,
394 '--command',
395 resume,
396 '--focus',
397 'true',
398 ])
399 // O Cmux responde "OK surface:33 pane:9 workspace:9"
400 const fresh = created.stdout.match(/surface:\d+/)?.[0]
401 if (created.exitCode !== 0 || !fresh) return fallback('O Cmux não abriu a aba nova.')
402 if (reg.tabTitle) {
403 await cmux($, ['rename-tab', '--workspace', reg.workspaceId, ...win, '--surface', fresh, '--title', reg.tabTitle])
404 }
405 await focus(reg.workspaceId, fresh, ws.windowId)
406}
407
408// ── Ciclo de vida ───────────────────────────────────────────────────────
409
410export const register: Register = (on, options) => {
411 dono = typeof options.dono === 'string' && options.dono.trim() ? options.dono.trim() : DONO_PADRAO
412
413 // ── Ciclo de vida ───────────────────────────────────────────────────────
414
415 on('session.start', async ($, e, next) => {
416 sessionId = await $.session.id()
417 await loadDecisions($)
418 await $.tool.register({
419 name: TOOL,
420 description:
421 `Registra uma decisão que só ${dono} pode tomar, sem travar a sessão. A resposta vem depois, por botões, ` +
422 'e chega como mensagem do plugin decisoes. Use para merge, deploy, escopo, prazo, aval, ' +
423 'ou escolha entre caminhos de custo ou risco diferentes. Não use para o que você pode decidir sozinho.',
424 inputSchema: {
425 type: 'object',
426 properties: {
427 pergunta: { type: 'string', description: 'A pergunta, curta e direta' },
428 contexto: { type: 'string', description: 'Até 2 frases com o que ele precisa saber para decidir' },
429 opcoes: {
430 type: 'array',
431 items: { type: 'string' },
432 maxItems: 9,
433 description: 'Opções curtas e mutuamente exclusivas',
434 },
435 recomendada: { type: 'integer', minimum: 1, maximum: 9, description: 'Número da opção recomendada' },
436 bloqueia: { type: 'boolean', description: 'true se você vai parar até a resposta' },
437 },
438 required: ['pergunta'],
439 },
440 })
441 await $.command.register({ name: 'decisoes', description: 'Abre as decisões pendentes desta sessão', immediate: true })
442 $.clock.every(HEARTBEAT_MS, () => {
443 heartbeat($).catch(() => {})
444 })
445 await heartbeat($)
446 return next(e)
447 })
448
449 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
450 await heartbeat($)
451 return next(e)
452 })
453
454 on('session.end', async ($, e, next) => {
455 if (sessionId) await markEnded($, sessionId)
456 return next(e)
457 })
458
459 on('command.run', { command: 'decisoes' }, async $ => {
460 await openPane($)
461 return {}
462 })
463
464 // ── Instrução para o agente ─────────────────────────────────────────────
465
466 on('prompt.compose', async ($, e, next) => {
467 const composed = await next(e)
468 return {
469 ...composed,
470 sections: [...composed.sections, { id: 'decisoes:instrucoes', text: instructions(dono), scope: 'session' as const }],
471 }
472 })
473
474 // ── Captura ─────────────────────────────────────────────────────────────
475
476 on('tool.call', { tool: TOOL_FULL }, async ($, e) => {
477 const input = e as unknown as Record<string, unknown>
478 const pergunta = typeof input.pergunta === 'string' ? input.pergunta.trim() : ''
479 if (!pergunta) return { deny: 'Informe a pergunta.' }
480 const opcoes = Array.isArray(input.opcoes)
481 ? input.opcoes.filter((o): o is string => typeof o === 'string' && o.trim().length > 0).slice(0, 9)
482 : []
483 const rec = typeof input.recomendada === 'number' && input.recomendada >= 1 && input.recomendada <= opcoes.length
484 registeredThisTurn = true
485 const id = await addDecision($, {
486 pergunta,
487 contexto: typeof input.contexto === 'string' && input.contexto.trim() ? input.contexto.trim() : undefined,
488 opcoes,
489 recomendada: rec ? (input.recomendada as number) : undefined,
490 bloqueia: input.bloqueia === true,
491 origem: 'ferramenta',
492 })
493 return {
494 result:
495 `Registrada como ${id}. ${capitalize(dono)} decide quando voltar e a resposta chega numa mensagem do plugin decisoes. ` +
496 'Siga com o que não depende disso ou pouse; não repita a pergunta no texto.',
497 }
498 })
499
500 on('turn.start', ($, e, next) => {
501 registeredThisTurn = false
502 turnRunning = true
503 return next(e)
504 })
505
506 on('turn.complete', async ($, e, next) => {
507 if (e.agentId === undefined) turnRunning = false
508 if (e.agentId === undefined && LANDED.test(e.answer) && (await read($, landing))) {
509 await update($, landing, () => null)
510 $.ui.toast('✓ Pouso concluído. Pode fechar a máquina.', { timeoutMs: 15_000 })
511 }
512 if (e.agentId === undefined && !e.isAborted && !registeredThisTurn) {
513 const lines = e.answer
514 .split('\n')
515 .filter(l => TEXT_MARKERS.test(l))
516 .map(cleanLine)
517 .filter(l => l.length > 3)
518 .slice(0, 3)
519 const existing = new Set((await read($, decisions)).filter(isPending).map(d => d.pergunta))
520 for (const pergunta of lines) {
521 if (existing.has(pergunta)) continue
522 await addDecision($, { pergunta, opcoes: [], bloqueia: true, origem: 'texto' })
523 }
524 }
525 registeredThisTurn = false
526 return next(e)
527 })
528
529 // ── Faixa acima do prompt (sempre visível) ──────────────────────────────
530
531 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
532 if (e.props.hasSurvey) return next(e)
533 const { Box, Text, Button } = $.ui.resolve(e)
534 const list = await read($, decisions)
535 const outras = await read($, others)
536 const now = (await read($, nowAtom)) || Date.now()
537 const pend = queue(list)
538 const answered = list.filter(d => d.estado === 'respondida')
539 const isBlocked = pend.some(d => d.bloqueia && d.estado === 'pendente')
540 const oldest = pend.length ? Math.min(...pend.map(d => d.criadaEm)) : 0
541 const pouso = await read($, landing)
542 const below = await next(e)
543
544 return (
545 <Box flexDirection="column">
546 <Box flexDirection="row" columnGap={1}>
547 {pend.length > 0 ? (
548 <Text>
549 <Text color="warning" bold>⚑ {pend.length} {pend.length === 1 ? 'decisão' : 'decisões'}</Text>
550 {' nesta sessão · mais antiga há '}
551 {ago(now - oldest)}
552 {isBlocked ? ' · ⏸ sessão parada esperando você' : ''}
553 </Text>
554 ) : (
555 <Text dimColor>⚑ nenhuma decisão pendente nesta sessão</Text>
556 )}
557 {pend.length > 0 && <Button key="abrir" label="decidir" variant="primary" onPress={() => openPane($)} />}
558 {answered.length > 0 && (
559 <Text dimColor>
560 · {answered.length} {answered.length === 1 ? 'respondida' : 'respondidas'} aguardando envio
561 </Text>
562 )}
563 {answered.length > 0 && <Button key="enviar" label="enviar agora" onPress={() => sendBatch($)} />}
564 <Text dimColor>│</Text>
565 {pouso ? (
566 <Text color="warning">
567 {pouso.kind === 'emergencia' ? '⚠ pouso de emergência' : '🛬 pouso organizado'} em andamento · há{' '}
568 {ago(now - pouso.startedAt)}
569 </Text>
570 ) : (
571 <Box flexDirection="row" columnGap={2}>
572 <Button key="pouso" plain dimColor label="🛬 pousar" onPress={() => requestLanding($, 'organizado')} />
573 <Button key="pouso-emergencia" plain dimColor label="⚠ pouso de emergência" onPress={() => requestLanding($, 'emergencia')} />
574 </Box>
575 )}
576 </Box>
577 {outras.length > 0 && (
578 <Box flexDirection="row" columnGap={2}>
579 <Text dimColor>outras ›</Text>
580 {outras.slice(0, 4).map(o => (
581 <Button
582 key={`outra-${o.sessionId}`}
583 plain
584 dimColor
585 label={`${o.label} (${o.pending})${o.isAlive ? '' : ' ○'}`}
586 onPress={() => goTo($, o.sessionId, o.label)}
587 />
588 ))}
589 </Box>
590 )}
591 {below ?? null}
592 </Box>
593 )
594 })
595
596 // ── Painel de decisão ───────────────────────────────────────────────────
597
598 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
599 // O painel é para o terminal e o app desktop; outras superfícies ficam com o desenho do engine
600 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
601 const { Box, Text, Button, Input } = $.ui.resolve(e)
602 const list = await read($, decisions)
603 const now = (await read($, nowAtom)) || Date.now()
604 const pend = queue(list)
605 const answered = list.filter(d => d.estado === 'respondida')
606 const close = () => $.ui.close({ id: PANE })
607
608 if (pend.length === 0) {
609 return (
610 <Box flexDirection="column">
611 <Text>{answered.length > 0 ? `Tudo respondido (${answered.length}).` : 'Nenhuma decisão pendente nesta sessão.'}</Text>
612 <Box flexDirection="row" columnGap={2}>
613 {answered.length > 0 && (
614 <Button key="enviar" label="Enviar ao Claude" variant="primary" autoFocus onPress={() => sendBatch($)} />
615 )}
616 <Button key="fechar" label="Fechar" role="dismiss" onPress={close} />
617 </Box>
618 </Box>
619 )
620 }
621
622 const d = pend[0]
623 if (!d) return next(e)
624 const width = Math.max(20, e.props.bodyColumns - 2)
625 return (
626 <Box flexDirection="column">
627 <Text bold>
628 Decisão 1 de {pend.length} · {d.id} · há {ago(now - d.criadaEm)}
629 {d.estado === 'adiada' ? ' · adiada' : ''}
630 {d.bloqueia ? ' · ⏸ sessão parada' : ''}
631 </Text>
632 <Text> </Text>
633 <Box width={width}>
634 <Text wrap="wrap">{d.pergunta}</Text>
635 </Box>
636 {d.contexto && (
637 <Box width={width}>
638 <Text dimColor wrap="wrap">{d.contexto}</Text>
639 </Box>
640 )}
641 <Text> </Text>
642 {d.opcoes.map((opt, i) => (
643 <Button
644 key={`opt-${d.id}-${i + 1}`}
645 plain
646 hotkey={String(i + 1)}
647 label={`${opt}${d.recomendada === i + 1 ? ' ★ recomendada' : ''}`}
648 onPress={() => answer($, d.id, `[${i + 1}] ${opt}`)}
649 />
650 ))}
651 <Input
652 key={`outra-${d.id}`}
653 label={d.opcoes.length > 0 ? 'Outra resposta' : 'Resposta'}
654 placeholder="digite e tecle Enter"
655 value=""
656 submitLabel="responder"
657 autoFocus={d.opcoes.length === 0 ? true : undefined}
658 onSubmit={(v: string) => answer($, d.id, v.trim())}
659 />
660 <Text> </Text>
661 <Box flexDirection="row" columnGap={3}>
662 <Button key="adiar" plain hotkey="a" label="adiar" onPress={() => postpone($, d.id)} />
663 {answered.length > 0 && (
664 <Button key="enviar" plain hotkey="e" label={`enviar ${answered.length} já respondida(s)`} onPress={() => sendBatch($)} />
665 )}
666 <Button key="fechar" plain hotkey="f" label="fechar" onPress={close} />
667 </Box>
668 <Text dimColor>clique ou tecle o número · Tab até a resposta livre · Esc fecha</Text>
669 </Box>
670 )
671 })
672}
673types/index.d.ts 58 lines1export type DecisionState = 'pendente' | 'adiada' | 'respondida' | 'enviada'
2
3export type Decision = {
4 /** D-1, D-2… único dentro da sessão */
5 id: string
6 seq: number
7 pergunta: string
8 contexto?: string
9 /** Opções de resposta (até 9); vazia quando a decisão veio do texto */
10 opcoes: string[]
11 /** Opção recomendada pelo agente, 1-based */
12 recomendada?: number
13 /** O agente parou esperando esta resposta */
14 bloqueia: boolean
15 origem: 'ferramenta' | 'texto'
16 criadaEm: number
17 estado: DecisionState
18 resposta?: string
19 respondidaEm?: number
20}
21
22/** Cadastro de uma sessão, gravado no armazenamento compartilhado */
23export type Registration = {
24 sessionId: string
25 cwd: string
26 workspaceId?: string
27 surfaceId?: string
28 windowId?: string
29 workspaceName?: string
30 tabTitle?: string
31 pending: number
32 oldestPendingAt?: number
33 lastSeen: number
34 ended?: boolean
35}
36
37/** Um pouso pedido pela faixa */
38export type Landing = { kind: 'organizado' | 'emergencia'; startedAt: number }
39
40/** Outra sessão com decisão pendente, como a faixa a mostra */
41export type OtherSession = {
42 sessionId: string
43 label: string
44 pending: number
45 isAlive: boolean
46}
47
48declare module 'claude-code' {
49 interface PluginState {
50 decisoes: {
51 decisions: Decision[]
52 others: OtherSession[]
53 now: number
54 landing: Landing | null
55 }
56 }
57}
58