SLOPSHOPPER

workers

Salão dos Deuses: subagents become Norse and Roman gods in a carved-stone pane (/workers) with runes, laurel, Roman-numeral progress, an oracle check and a war…

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · workers
│ ┃ Salão dos Deuses ✕ › fix the failing auth test and add an audit log call │ ┃ ᛟ S A L Ã O D O S D E U S E S ᛟ │ ┃ SALA·DEORVM · APP · ÆSIR · VANIR · DI ROMANI ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ N triunfos · N em batalha · N tombados ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ── O ORÁCULO FALA · 08:53 UTC ── ⏺ Bash(bun test) │ ┃ FAVORÁVEL nenhum deus ocioso ou preso no ⎿ 3 pass, 1 fail │ ┃ campo │ ┃ FAVORÁVEL nenhum worktree esquecido no ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ campo │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ ── ⚔ EM BATALHA ⚔ ── │ ┃ › /workers │ ┃ O salão aguarda. Convoque um subagente e um ⎿ workers: Os portões do Salão dos Deuses se abrem. │ ┃ deus atende. │ ┃ │ ┃ [ Consultar o oráculo ] [ Dispensar os que │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Salão dos Deuses
ᛟ S A L Ã O D O S D E U S E S ᛟ SALA·DEORVM · APP · ÆSIR · VANIR · DI ROMANI N triunfos · N em batalha · N tombados ── O ORÁCULO FALA · 08:53 UTC ── FAVORÁVEL nenhum deus ocioso ou preso no campo FAVORÁVEL nenhum worktree esquecido no campo ── ⚔ EM BATALHA ⚔ ── O salão aguarda. Convoque um subagente e um deus atende. [ Consultar o oráculo ] [ Dispensar os que terminaram ]
README

tauan-mods

My Claude Code mods in one marketplace. /mods opens one pane with a tab per mod: no per-mod slash commands to remember.

ModSourceWhat it does
modstauantcamargo/claude-mods-hub (fork of mvacoimbra's, plus a Workers tab)The /mods pane
workersplugins/workersSalão dos Deuses: every subagent becomes a Norse or Roman god, in a carved-stone pane (/workers) with runes, laurel, Roman-numeral progress, an oracle check and a war band above the prompt
muninnplugins/muninnMuninn, Odin's raven of memory: your ai-memory in a /muninn pane and a /mods tab (the project's memory, the last handoff, search, recent pages), a count in the status line and a toast when a handoff arrives. Also for the Codex app, below
cache-taxmvacoimbra forkKeeps the prompt cache warm (costs tokens)
skinsmvacoimbra forkThemes and richer tool rows
filetreemvacoimbra forkFile tree of what Claude touches
savvy-progressmvacoimbra forkSubagent progress and agents panel
replay-theatermvacoimbra forkReplay the last turn's edits
blast-radiusmvacoimbra/claude-code-playgroundHolds risky shell commands for confirmation
reflect-modmvacoimbra/claude-reflectSaves your corrections to CLAUDE.md

Install

claude plugin marketplace add tauantcamargo/claude-mods
claude plugin install mods@tauan-mods
claude plugin install workers@tauan-mods
claude plugin install muninn@tauan-mods
claude plugin install cache-tax@tauan-mods
claude plugin install skins@tauan-mods
claude plugin install filetree@tauan-mods
claude plugin install savvy-progress@tauan-mods
claude plugin install replay-theater@tauan-mods
claude plugin install blast-radius@tauan-mods
claude plugin install reflect-mod@tauan-mods

Restart Claude Code, then open /mods. Uninstall the same mods from their original marketplaces first so nothing loads twice.

Codex

The same marketplace serves Codex (.agents/plugins/marketplace.json). Muninn there is an MCP App (codex/muninn): the Codex app draws its panel, and the $muninn skill points Codex at it.

codex plugin marketplace add tauantcamargo/claude-mods
codex plugin add muninn@tauan-mods
Source 3 files
hooks/register.tsx 505 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Check, Verification, Worker, WorkersActArgs, WorkersActResult, WorkersItem, WorkersPanel, WorkersPanelArgs } from '../types'
5import {
6  accentOf, bannerSvg, C, clip, doneCardSvg, elapsed, glyphOf, GODS, godOf, heroSvg, hhmm,
7  oracleSvg, pantheonLabel, pickGod, progressOf, roman, workerCardSvg,
8} from './art'
9import type { PantheonChoice } from './art'
10
11export { GODS, pickGod }
12
13// Salão dos Deuses: every subagent is a worker named after a Norse or Roman god,
14// shown in a carved-stone pane (/workers) and a band above the prompt while any fights.
15
16const PANE = 'workers'
17const TITLE = 'Salão dos Deuses'
18const STEP_NAME = 'step'
19const STEP_TOOL = 'mcp__workers__step'
20
21const workers = atom({ plugin: 'workers', key: 'workers' } as const, [])
22const verification = atom({ plugin: 'workers', key: 'verification' } as const, null)
23const autoOpened = atom({ plugin: 'workers', key: 'autoOpened' } as const, false)
24const now = atom({ plugin: 'workers', key: 'now' } as const, 0)
25
26export function extractTldr(answer: string): string | undefined {
27  const lines = answer.split('\n').map(l => l.trim()).filter(Boolean)
28  for (let i = lines.length - 1; i >= 0; i--) {
29    const m = /^\**\s*TL;?DR\s*:?\**\s*:?\s*(.+)$/i.exec(lines[i] ?? '')
30    if (m?.[1]) return clip(m[1].replace(/\*+$/, '').trim(), 160)
31  }
32  const first = lines.find(l => !l.startsWith('#'))
33  return first ? clip(first.replace(/[*_`]/g, ''), 120) : undefined
34}
35
36const introOf = (name: string): string => {
37  const god = godOf(name)
38  const where = god.pantheon === 'romani' ? 'do panteão romano' : god.pantheon === 'vanir' ? 'dos Vanir' : 'dos Æsir'
39  return god.domain ? `${name}, ${god.pantheon === 'romani' ? `deus de ${god.domain}` : god.domain}, ${where}` : name
40}
41
42const brief = (name: string): string =>
43  `\n\n---\nVocê é o worker **${introOf(name)}**. Regras do worker:\n` +
44  `- Logo após ler a tarefa, chame a ferramenta \`${STEP_TOOL}\` com \`total\` (seu plano em 3-8 passos) e \`done: 0\`; chame de novo a cada passo concluído, com \`note\` curta.\n` +
45  `- Termine sua resposta final com uma última linha \`TL;DR: <uma frase>\`.\n` +
46  `- Se você criou um git worktree e o trabalho terminou (commitado/mergeado), remova-o (\`git worktree remove\`, \`git worktree prune\`, apague a branch local mergeada). Nunca remova um com trabalho não commitado ou não mergeado; avise em vez disso.`
47
48const SYSTEM = `# Workers (Salão dos Deuses)
49Every subagent you launch is a "worker" named after a Norse or Roman god (the name is added to its description automatically, e.g. "Thor · fix login", "Minerva · plan migration"). Refer to workers by their god name when you talk about them.
50When the user addresses a worker by name ("Thor, ..."), route that message to that worker: continue it with SendMessage (by its agent id or name) if it is still reachable; otherwise launch a new subagent for the same topic and say so.
51When a worker's job is fully done, make sure no idle leftovers remain: its git worktree removed (never one with uncommitted or unmerged work — tell the user instead).
52End every milestone reply with a final line \`TL;DR: <one sentence>\`.`
53
54// --- the oracle: what a refresh re-checks
55
56async function verify($: EngineInterface): Promise<Verification> {
57  const at = await $.clock.now()
58  const list = await read($, workers)
59  const lines: Check[] = []
60  const running = list.filter(w => w.status === 'running')
61  const failed = list.filter(w => w.status === 'failed')
62  const done = list.filter(w => w.status === 'done')
63
64  lines.push(running.length
65    ? { level: 'wait', text: `${running.length === 1 ? '1 deus' : `${running.length} deuses`} em batalha: ${running.map(w => w.god).join(', ')}` }
66    : { level: 'ok', text: 'nenhum deus ocioso ou preso no campo' })
67  if (done.length) lines.push({ level: 'ok', text: `${done.length === 1 ? '1 triunfo' : `${done.length} triunfos`} nesta jornada` })
68  for (const w of failed) lines.push({ level: 'warn', text: `${w.god} tombou · ${clip(w.topic, 60)}` })
69  for (const w of running) {
70    if (!w.stepTotal && at - w.startedAt > 120_000) lines.push({ level: 'warn', text: `${w.god} em silêncio há ${elapsed(w.startedAt, at)}, sem reportar progresso` })
71  }
72
73  try {
74    const cwd = await $.session.cwd()
75    const run = await $.process.run(['git', 'worktree', 'list', '--porcelain'], { cwd, timeoutMs: 5000 })
76    if (run.exitCode === 0) {
77      const blocks = run.stdout.split('\n\n').map(b => b.trim()).filter(Boolean).slice(1)
78      if (blocks.length === 0) lines.push({ level: 'ok', text: 'nenhum worktree esquecido no campo' })
79      else {
80        const merged = await $.process.run(['git', 'branch', '--merged'], { cwd, timeoutMs: 5000 })
81        const mergedSet = new Set(merged.stdout.split('\n').map(l => l.replace(/^[*+ ]+/, '').trim()).filter(Boolean))
82        for (const block of blocks) {
83          const path = /worktree (.+)/.exec(block)?.[1] ?? '?'
84          const branch = /branch refs\/heads\/(.+)/.exec(block)?.[1]
85          const name = path.split('/').pop() ?? path
86          if (branch && mergedSet.has(branch)) lines.push({ level: 'warn', text: `worktree ${name} (${branch}) já mergeado — pode ser limpo` })
87          else lines.push({ level: 'wait', text: `worktree ${name}${branch ? ` (${branch})` : ''} ainda em uso` })
88        }
89      }
90    }
91  } catch {
92    // Not a git repo, or git missing: the oracle says nothing of worktrees.
93  }
94
95  const v = { at, lines }
96  await update($, verification, () => v)
97  await update($, now, () => at)
98  return v
99}
100
101async function openPane($: EngineInterface): Promise<void> {
102  await update($, now, () => Date.now())
103  await $.ui.open({ id: PANE, title: TITLE })
104}
105
106async function togglePane($: EngineInterface): Promise<boolean> {
107  const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
108  if (isOpen) {
109    await $.ui.close({ id: PANE })
110    return false
111  }
112  await verify($)
113  await openPane($)
114  return true
115}
116
117const clearFinished = ($: EngineInterface) => update($, workers, l => l.filter(w => w.status === 'running'))
118
119// --- the /mods hub tab (PROTOCOL.md of claude-mods-hub)
120
121const PANTHEON_KEY = 'pantheon'
122
123async function pantheonChoice($: EngineInterface): Promise<PantheonChoice> {
124  const v = await $.store.get(PANTHEON_KEY).catch(() => undefined)
125  return v === 'norse' || v === 'roman' ? v : 'both'
126}
127
128const WORDS = {
129  pt: {
130    title: 'Workers', hall: 'Salão', gods: 'Deuses', settings: 'Ajustes',
131    fighting: 'Em batalha', triumphs: 'Triunfos', fallen: 'Tombados',
132    open: 'Abrir o Salão dos Deuses', openHelp: 'O painel com escudos, runas e louros',
133    oracle: 'Consultar o oráculo', oracleHelp: 'Re-verifica workers e worktrees esquecidos',
134    clean: 'Dispensar os que terminaram', cleanHelp: 'Tira do painel os que triunfaram ou tombaram',
135    pantheon: 'Panteão', pantheonHelp: 'De onde vêm os nomes dos próximos workers',
136    both: 'Nórdicos + romanos', norse: 'Só nórdicos', roman: 'Só romanos',
137    none: 'O salão aguarda. Convoque um subagente e um deus atende.',
138    summary: (r: number, d: number) => (r ? `${r} em batalha · ${d} triunfo(s)` : d ? `${d} triunfo(s) · o salão descansa` : 'o salão aguarda'),
139  },
140  en: {
141    title: 'Workers', hall: 'Hall', gods: 'Gods', settings: 'Settings',
142    fighting: 'In battle', triumphs: 'Triumphs', fallen: 'Fallen',
143    open: 'Open the Hall of the Gods', openHelp: 'The pane with shields, runes and laurel',
144    oracle: 'Consult the oracle', oracleHelp: 'Re-checks workers and leftover worktrees',
145    clean: 'Dismiss the finished', cleanHelp: 'Removes those who triumphed or fell from the pane',
146    pantheon: 'Pantheon', pantheonHelp: 'Where the next workers take their names from',
147    both: 'Norse + Roman', norse: 'Norse only', roman: 'Roman only',
148    none: 'The hall waits. Summon a subagent and a god answers.',
149    summary: (r: number, d: number) => (r ? `${r} in battle · ${d} triumph(s)` : d ? `${d} triumph(s) · the hall rests` : 'the hall waits'),
150  },
151} as const
152
153async function hubPanel($: EngineInterface, args: WorkersPanelArgs): Promise<WorkersPanel> {
154  const w = WORDS[args.lang === 'en' ? 'en' : 'pt']
155  const list = await read($, workers)
156  const t = await $.clock.now()
157  const running = list.filter(x => x.status === 'running')
158  const done = list.filter(x => x.status === 'done').length
159  const fallen = list.filter(x => x.status === 'failed').length
160  const gods: WorkersItem[] = running.length
161    ? running.map(x => {
162        const g = godOf(x.god)
163        const step = x.stepTotal ? ` · ${roman(x.stepDone ?? 0)}/${roman(x.stepTotal)}` : ''
164        return { kind: 'info', label: `${glyphOf(g)} ${x.god}`, value: `${clip(x.topic, 48)}${step} · ${elapsed(x.startedAt, t)}`, tone: 'accent' }
165      })
166    : [{ kind: 'text', text: w.none, tone: 'muted' }]
167  for (const x of list.filter(y => y.status !== 'running').reverse().slice(0, 8)) {
168    gods.push({ kind: 'info', label: `${glyphOf(godOf(x.god))} ${x.god}`, value: clip(x.tldr ?? x.topic, 60), tone: x.status === 'done' ? 'ok' : 'err' })
169  }
170  const choice = await pantheonChoice($)
171  return {
172    title: w.title,
173    summary: w.summary(running.length, done),
174    tone: fallen ? 'warn' : running.length ? 'accent' : 'ok',
175    refreshMs: running.length ? 5000 : undefined,
176    sections: [
177      {
178        id: 'hall', title: w.hall, items: [
179          { kind: 'info', label: w.fighting, value: roman(running.length), tone: 'accent' },
180          { kind: 'info', label: w.triumphs, value: roman(done), tone: 'ok' },
181          { kind: 'info', label: w.fallen, value: roman(fallen), tone: fallen ? 'err' : 'muted' },
182          { kind: 'action', id: 'open', label: w.open, help: w.openHelp, primary: true },
183          { kind: 'action', id: 'oracle', label: w.oracle, help: w.oracleHelp },
184          { kind: 'action', id: 'clean', label: w.clean, help: w.cleanHelp },
185        ],
186      },
187      { id: 'gods', title: w.gods, items: gods },
188      {
189        id: 'settings', title: w.settings, items: [
190          { kind: 'choice', id: 'pantheon', label: w.pantheon, help: w.pantheonHelp, value: choice, options: [
191            { value: 'both', label: w.both }, { value: 'norse', label: w.norse }, { value: 'roman', label: w.roman },
192          ] },
193        ],
194      },
195    ],
196  }
197}
198
199async function hubAct($: EngineInterface, args: WorkersActArgs): Promise<WorkersActResult> {
200  switch (args.id) {
201    case 'open':
202      await verify($)
203      await openPane($)
204      return { message: 'Os portões do Salão dos Deuses se abrem.' }
205    case 'oracle': {
206      const v = await verify($)
207      return { message: `O oráculo falou: ${v.lines.length} presságio(s).` }
208    }
209    case 'clean': {
210      const before = (await read($, workers)).length
211      await clearFinished($)
212      return { message: `${before - (await read($, workers)).length} dispensado(s).` }
213    }
214    case 'pantheon': {
215      const v = args.value === 'norse' || args.value === 'roman' ? args.value : 'both'
216      await $.store.set(PANTHEON_KEY, v)
217      return { message: v === 'norse' ? 'Só deuses nórdicos daqui em diante.' : v === 'roman' ? 'Só deuses romanos daqui em diante.' : 'Nórdicos e romanos, lado a lado.' }
218    }
219    default:
220      return {}
221  }
222}
223
224const OMEN = { ok: { color: C.laurel, word: 'FAVORÁVEL' }, warn: { color: C.fallen, word: 'MAU PRESSÁGIO' }, wait: { color: C.ember, word: 'AGUARDANDO' } } as const
225
226// --- the module
227
228export const register: Register = on => {
229  // Adds `$.workers`: its tab in the /mods pane.
230  on('engine.create', async ($, e, next) => {
231    const built = await next(e)
232    return { ...built, workers: { panel: async () => ({ title: '', sections: [] }), act: async () => ({}) } }
233  })
234  on('workers.panel', async ($, e) => ({ value: await hubPanel($, e) }))
235  on('workers.act', async ($, e) => ({ value: await hubAct($, e) }))
236
237  on('session.start', async ($, e, next) => {
238    const started = await next(e)
239    await $.tool.register({
240      name: STEP_NAME,
241      description:
242        'For workers (subagents): report progress on your own task to the Workers pane. ' +
243        'Right after reading the task, call it with `total` (your plan in 3-8 steps) and `done: 0`; ' +
244        'call it again as each step finishes, with a short `note`. Cheap and silent.',
245      inputSchema: {
246        type: 'object',
247        properties: {
248          done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
249          total: { type: 'integer', minimum: 1, description: 'Steps planned; may change.' },
250          note: { type: 'string', description: 'The step in progress, a few words.' },
251        },
252        required: ['done'],
253      },
254      isDeferred: false,
255    })
256    await $.command.register({
257      name: 'workers',
258      description: 'Salão dos Deuses: abre/fecha o painel. `/workers oraculo` re-verifica, `/workers limpar` dispensa os que terminaram',
259    })
260    // Ticks the clocks of gods in battle; silent when the hall rests.
261    $.clock.every(5000, () => {
262      void (async () => {
263        const list = await read($, workers)
264        if (!list.some(w => w.status === 'running')) return
265        const at = await $.clock.now()
266        await update($, now, () => at)
267      })()
268    })
269    return started
270  })
271
272  on('prompt.compose', async ($, e, next) => {
273    const composed = await next(e)
274    if (!e.tools.includes('Agent')) return composed
275    return { sections: [...composed.sections, { id: 'workers:rules', text: SYSTEM, scope: 'session' as const }] }
276  })
277
278  on('command.run', { command: 'workers' }, async ($, e) => {
279    const arg = (e.args ?? '').trim().toLowerCase()
280    if (['refresh', 'atualizar', 'oraculo', 'oráculo'].includes(arg)) {
281      const v = await verify($)
282      await openPane($)
283      return { text: `O oráculo falou às ${hhmm(v.at)} UTC (${v.lines.length} presságios).` }
284    }
285    if (['clean', 'limpar'].includes(arg)) {
286      const before = (await read($, workers)).length
287      await clearFinished($)
288      const removed = before - (await read($, workers)).length
289      await verify($)
290      return { text: `${removed} deus(es) dispensado(s) do salão.` }
291    }
292    if (arg === 'open' || arg === 'abrir') {
293      await verify($)
294      await openPane($)
295      return { text: 'Os portões do Salão dos Deuses se abrem.' }
296    }
297    const isOpen = await togglePane($)
298    return { text: isOpen ? 'Os portões do Salão dos Deuses se abrem.' : 'Os portões do salão se fecham.' }
299  })
300
301  on('tool.call', { tool: STEP_TOOL as never }, async ($, e) => {
302    const input = e as unknown as { done?: number; total?: number; note?: string; agentId?: string }
303    const agentId = input.agentId
304    if (!agentId) return { result: 'ignored: only workers (subagents) report steps' }
305    let matched = false
306    await update($, workers, list =>
307      list.map(w => {
308        if (w.agentId !== agentId) return w
309        matched = true
310        const total = Math.max(0, Math.round(input.total ?? w.stepTotal ?? 0))
311        const done = Math.max(0, Math.round(input.done ?? w.stepDone ?? 0))
312        return { ...w, stepTotal: total, stepDone: total ? Math.min(total, done) : done, stepNote: input.note?.trim() || w.stepNote }
313      }),
314    )
315    return { result: matched ? 'ok' : `ignored: no worker for agent ${agentId}` }
316  }).catch(() => ({ result: 'ok' }))
317
318  on('agent.spawn', async ($, e, next) => {
319    const list = await read($, workers)
320    const used = list.map(w => w.god)
321    const already = GODS.find(g => e.description.startsWith(`${g} · `))
322    const god = already ?? pickGod(used, (await $.clock.now()) + list.length, `${e.subagentType} ${e.description}`, await pantheonChoice($))
323    const topic = already ? e.description.slice(already.length + 3) : e.description
324    const started = await next({
325      ...e,
326      description: already ? e.description : `${god} · ${e.description}`,
327      prompt: e.prompt.includes(STEP_TOOL) ? e.prompt : e.prompt + brief(god),
328    })
329    if (started.deny !== undefined) return started
330
331    const at = await $.clock.now()
332    const w: Worker = {
333      id: started.agentId ?? e.tool_use_id,
334      agentId: started.agentId,
335      god,
336      topic,
337      type: e.subagentType,
338      status: 'running',
339      startedAt: at,
340    }
341    await update($, workers, prev => [...prev.filter(x => x.id !== w.id), w].slice(-100))
342    await update($, now, () => at)
343    if (!(await read($, autoOpened))) {
344      await update($, autoOpened, () => true)
345      void openPane($)
346    }
347    return started
348  }).catch(($, e, next) => (next.called ? undefined : next(e)))
349
350  on('turn.complete', async ($, e, next) => {
351    const agentId = e.agentId
352    if (agentId) {
353      const at = await $.clock.now()
354      const tldr = extractTldr(e.answer)
355      let name: string | undefined
356      await update($, workers, list =>
357        list.map(w => {
358          if (w.agentId !== agentId) return w
359          name = w.god
360          const ok = e.reason === 'answer'
361          return { ...w, status: ok ? 'done' : 'failed', endedAt: at, tldr: tldr ?? w.tldr, stepDone: ok && w.stepTotal ? w.stepTotal : w.stepDone }
362        }),
363      )
364      await update($, now, () => at)
365      if (name) {
366        const isRoman = godOf(name).pantheon === 'romani'
367        $.ui.toast(e.reason === 'answer'
368          ? (isRoman ? `🏛 ${name} celebra seu triunfo` : `⚔ ${name} entra em Valhalla`)
369          : `${name} tombou em batalha (${e.reason})`)
370      }
371    }
372    return next(e)
373  })
374
375  // A war banner above the prompt while any god fights.
376  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
377    const list = await read($, workers)
378    const running = list.filter(w => w.status === 'running')
379    if (running.length === 0) return next(e)
380    const { Box, Text, Button } = $.ui.resolve(e)
381    return (
382      <Box flexDirection="row" gap={1}>
383        <Text color={C.gold} bold>⚔ SALÃO</Text>
384        {running.slice(0, 4).map(w => {
385          const god = godOf(w.god)
386          return (
387            <Text color={accentOf(god)}>
388              {glyphOf(god)} {w.god}{w.stepTotal ? ` ${roman(w.stepDone ?? 0)}/${roman(w.stepTotal)}` : ' …'}
389            </Text>
390          )
391        })}
392        {running.length > 4 && <Text color={C.muted}>+{roman(running.length - 4)}</Text>}
393        <Button key="workers-open" label="salão" plain onPress={() => void togglePane($)} />
394      </Box>
395    )
396  })
397
398  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
399    const list = await read($, workers)
400    const v = await read($, verification)
401    const t = Math.max(await read($, now), Date.now())
402    const running = list.filter(w => w.status === 'running')
403    const finished = list.filter(w => w.status !== 'running').reverse()
404    let project = 'jornada'
405    try {
406      project = (await $.session.cwd()).split('/').filter(Boolean).pop() ?? project
407    } catch {
408      // keep the default
409    }
410
411    if (e.surface === 'terminal') {
412      const { Box, Text, Button } = $.ui.resolve(e)
413      const done = finished.filter(w => w.status === 'done').length
414      const fallen = finished.length - done
415      return (
416        <Box flexDirection="column" gap={1}>
417          <Box flexDirection="column">
418            <Text color={C.gold} bold>ᛟ  S A L Ã O   D O S   D E U S E S  ᛟ</Text>
419            <Text color={C.muted}>SALA·DEORVM · {project.toUpperCase()} · ÆSIR · VANIR · DI ROMANI</Text>
420          </Box>
421          <Text>
422            <Text color={C.laurel}>{roman(done)} triunfos</Text>
423            <Text color={C.muted}>  ·  </Text>
424            <Text color={C.ember}>{roman(running.length)} em batalha</Text>
425            <Text color={C.muted}>  ·  </Text>
426            <Text color={C.fallen}>{roman(fallen)} tombados</Text>
427          </Text>
428          {v && (
429            <Box flexDirection="column">
430              <Text color={C.gold}>── O ORÁCULO FALA · {hhmm(v.at)} UTC ──</Text>
431              {v.lines.map(l => (
432                <Text>
433                  <Text color={OMEN[l.level].color}>{OMEN[l.level].word.padEnd(14)}</Text>
434                  <Text color={C.parchment}>{l.text}</Text>
435                </Text>
436              ))}
437            </Box>
438          )}
439          <Text color={C.gold}>── ⚔ EM BATALHA ⚔ ──</Text>
440          {running.length === 0 && <Text color={C.muted}>O salão aguarda. Convoque um subagente e um deus atende.</Text>}
441          {running.map(w => {
442            const god = godOf(w.god)
443            const filled = Math.round(progressOf(w) * 20)
444            const accent = accentOf(god)
445            return (
446              <Box flexDirection="column">
447                <Text>
448                  <Text color={accent} bold>{glyphOf(god)} {w.god.toUpperCase()}</Text>
449                  <Text color={C.muted}> · {god.domain} · {pantheonLabel(god.pantheon)} · {elapsed(w.startedAt, t)}</Text>
450                </Text>
451                <Text color={C.goldHi}>  {w.topic}</Text>
452                <Text>
453                  {'  '}<Text color={accent}>{'▮'.repeat(filled)}</Text><Text color={C.dim}>{'▯'.repeat(20 - filled)}</Text>
454                  <Text color={C.muted}>{w.stepTotal ? ` ${roman(w.stepDone ?? 0)}/${roman(w.stepTotal)}` : ''} {w.stepNote ?? ''}</Text>
455                </Text>
456              </Box>
457            )
458          })}
459          {finished.length > 0 && <Text color={C.gold}>── SALÃO DOS TRIUNFOS ──</Text>}
460          {finished.slice(0, 12).map(w => {
461            const god = godOf(w.god)
462            const ok = w.status === 'done'
463            return (
464              <Text>
465                <Text color={ok ? C.laurel : C.fallen}>{glyphOf(god)} {w.god}</Text>
466                <Text color={C.muted}> {ok ? (god.pantheon === 'romani' ? 'triumphus' : 'valhalla') : 'tombou'} · </Text>
467                <Text color={C.parchment}>{clip(w.tldr ?? w.topic, 64)}</Text>
468              </Text>
469            )
470          })}
471          <Box flexDirection="row" gap={2}>
472            <Button key="w-refresh" label="Consultar o oráculo" onPress={() => void verify($)} />
473            <Button key="w-clean" label="Dispensar os que terminaram" onPress={() => void clearFinished($)} />
474          </Box>
475        </Box>
476      )
477    }
478
479    const { Box, Text, Button, Svg } = $.ui.resolve(e)
480    return (
481      <Box flexDirection="column" gap={1} padding={1} backgroundColor={C.bg}>
482        <Svg source={heroSvg(project, list, t)} alt={`Salão dos Deuses: ${running.length} em batalha, ${finished.length} retornaram`} />
483        {v && <Svg source={oracleSvg(v.lines, v.at)} alt={`Oráculo: ${v.lines.map(l => l.text).join('; ')}`} />}
484        <Svg source={bannerSvg('⚔  EM BATALHA  ⚔', running.length ? `${roman(running.length)} DEUS${running.length === 1 ? '' : 'ES'}` : '')} alt="Em batalha" />
485        {running.length === 0 && <Text color={C.muted} italic>O salão aguarda. Convoque um subagente e um deus atende.</Text>}
486        <Box flexDirection="row" flexWrap="wrap" gap={1}>
487          {running.map(w => (
488            <Svg key={`run-${w.id}`} source={workerCardSvg(w, t)} alt={`${w.god}: ${w.topic}`} />
489          ))}
490        </Box>
491        {finished.length > 0 && <Svg source={bannerSvg('SALÃO DOS TRIUNFOS')} alt="Salão dos triunfos" />}
492        <Box flexDirection="row" flexWrap="wrap" gap={1}>
493          {finished.slice(0, 18).map(w => (
494            <Svg key={`done-${w.id}`} source={doneCardSvg(w)} alt={`${w.god}: ${w.tldr ?? w.topic}`} />
495          ))}
496        </Box>
497        <Box flexDirection="row" gap={2}>
498          <Button key="w-refresh" label="Consultar o oráculo" variant="primary" onPress={() => void verify($)} />
499          <Button key="w-clean" label="Dispensar os que terminaram" variant="secondary" onPress={() => void clearFinished($)} />
500        </Box>
501      </Box>
502    )
503  })
504}
505
hooks/art.ts 413 lines
1// The look of the Salão dos Deuses: Norse and Roman pantheons, carved stone,
2// gold leaf, runes and laurel. Pure functions only (no engine calls), so the
3// drawings can be rendered and checked outside a session.
4
5import type { Check, Worker } from '../types'
6
7export type Pantheon = 'aesir' | 'vanir' | 'romani'
8
9export type God = {
10  name: string
11  pantheon: Pantheon
12  domain: string
13  /** Norse: the god's rune (key of RUNES). Roman: the initial on the scutum. */
14  sigil: string
15}
16
17// --- elder futhark, drawn as strokes in a 10 × 16 box (fonts need not carry them)
18
19export const RUNES: Record<string, { char: string; d: string }> = {
20  fehu: { char: 'ᚠ', d: 'M3 0V16M3 4L9 0M3 8L9 4' },
21  uruz: { char: 'ᚢ', d: 'M2 16V0L8 5V16' },
22  thurisaz: { char: 'ᚦ', d: 'M3 0V16M3 4L8 8L3 12' },
23  ansuz: { char: 'ᚨ', d: 'M3 0V16M3 0L9 4M3 5L9 9' },
24  raidho: { char: 'ᚱ', d: 'M3 16V0L8 4L3 8L8 16' },
25  kenaz: { char: 'ᚲ', d: 'M8 1L3 8L8 15' },
26  gebo: { char: 'ᚷ', d: 'M1 1L9 15M9 1L1 15' },
27  wunjo: { char: 'ᚹ', d: 'M3 16V0L8 4L3 8' },
28  hagalaz: { char: 'ᚺ', d: 'M2 0V16M8 0V16M2 5L8 11' },
29  naudiz: { char: 'ᚾ', d: 'M5 0V16M2 5L8 10' },
30  isa: { char: 'ᛁ', d: 'M5 0V16' },
31  jera: { char: 'ᛃ', d: 'M4 2L1 6L4 10M6 6L9 10L6 14' },
32  tiwaz: { char: 'ᛏ', d: 'M5 0V16M1 4L5 0L9 4' },
33  berkano: { char: 'ᛒ', d: 'M3 0V16M3 0L8 4L3 8L8 12L3 16' },
34  ehwaz: { char: 'ᛖ', d: 'M2 16V0L5 5L8 0V16' },
35  mannaz: { char: 'ᛗ', d: 'M2 16V0L8 6M8 16V0L2 6' },
36  laguz: { char: 'ᛚ', d: 'M3 16V0L8 5' },
37  ingwaz: { char: 'ᛜ', d: 'M5 3L9 8L5 13L1 8Z' },
38  othala: { char: 'ᛟ', d: 'M5 1L9 6L2 15M5 1L1 6L8 15' },
39  sowilo: { char: 'ᛊ', d: 'M7 0L3 6L7 10L3 16' },
40  algiz: { char: 'ᛉ', d: 'M5 0V16M1 2L5 7L9 2' },
41  dagaz: { char: 'ᛞ', d: 'M1 2V14L9 2V14L1 2' },
42}
43
44const RUNE_ORDER = Object.keys(RUNES)
45
46export const PANTHEON: readonly God[] = [
47  { name: 'Odin', pantheon: 'aesir', domain: 'pai de todos', sigil: 'ansuz' },
48  { name: 'Thor', pantheon: 'aesir', domain: 'senhor do trovão', sigil: 'thurisaz' },
49  { name: 'Tyr', pantheon: 'aesir', domain: 'guerra e justiça', sigil: 'tiwaz' },
50  { name: 'Frigg', pantheon: 'aesir', domain: 'presságio', sigil: 'berkano' },
51  { name: 'Heimdall', pantheon: 'aesir', domain: 'vigia de Bifröst', sigil: 'hagalaz' },
52  { name: 'Baldur', pantheon: 'aesir', domain: 'a luz', sigil: 'sowilo' },
53  { name: 'Loki', pantheon: 'aesir', domain: 'astúcia e fogo', sigil: 'kenaz' },
54  { name: 'Idun', pantheon: 'aesir', domain: 'renovação', sigil: 'jera' },
55  { name: 'Bragi', pantheon: 'aesir', domain: 'poesia', sigil: 'wunjo' },
56  { name: 'Vidar', pantheon: 'aesir', domain: 'o silêncio', sigil: 'dagaz' },
57  { name: 'Forseti', pantheon: 'aesir', domain: 'conciliação', sigil: 'mannaz' },
58  { name: 'Sif', pantheon: 'aesir', domain: 'campos dourados', sigil: 'gebo' },
59  { name: 'Ullr', pantheon: 'aesir', domain: 'caça e inverno', sigil: 'uruz' },
60  { name: 'Mimir', pantheon: 'aesir', domain: 'memória', sigil: 'othala' },
61  { name: 'Hermod', pantheon: 'aesir', domain: 'mensageiro', sigil: 'raidho' },
62  { name: 'Eir', pantheon: 'aesir', domain: 'cura', sigil: 'algiz' },
63  { name: 'Skadi', pantheon: 'aesir', domain: 'gelo e montanha', sigil: 'isa' },
64  { name: 'Hel', pantheon: 'aesir', domain: 'o submundo', sigil: 'naudiz' },
65  { name: 'Freyr', pantheon: 'vanir', domain: 'colheita e paz', sigil: 'fehu' },
66  { name: 'Freya', pantheon: 'vanir', domain: 'magia e seiðr', sigil: 'laguz' },
67  { name: 'Njord', pantheon: 'vanir', domain: 'mares e ventos', sigil: 'ehwaz' },
68  { name: 'Nerthus', pantheon: 'vanir', domain: 'a terra', sigil: 'ingwaz' },
69  { name: 'Júpiter', pantheon: 'romani', domain: 'rei dos deuses', sigil: 'I' },
70  { name: 'Juno', pantheon: 'romani', domain: 'rainha do Olimpo', sigil: 'I' },
71  { name: 'Minerva', pantheon: 'romani', domain: 'sabedoria e estratégia', sigil: 'M' },
72  { name: 'Marte', pantheon: 'romani', domain: 'a guerra', sigil: 'M' },
73  { name: 'Vênus', pantheon: 'romani', domain: 'amor e beleza', sigil: 'V' },
74  { name: 'Apolo', pantheon: 'romani', domain: 'luz e profecia', sigil: 'A' },
75  { name: 'Diana', pantheon: 'romani', domain: 'caça e lua', sigil: 'D' },
76  { name: 'Mercúrio', pantheon: 'romani', domain: 'mensageiro', sigil: 'M' },
77  { name: 'Vulcano', pantheon: 'romani', domain: 'a forja', sigil: 'V' },
78  { name: 'Netuno', pantheon: 'romani', domain: 'os mares', sigil: 'N' },
79  { name: 'Vesta', pantheon: 'romani', domain: 'o lar', sigil: 'V' },
80  { name: 'Ceres', pantheon: 'romani', domain: 'a colheita', sigil: 'C' },
81  { name: 'Baco', pantheon: 'romani', domain: 'o vinho', sigil: 'B' },
82  { name: 'Jano', pantheon: 'romani', domain: 'portais e começos', sigil: 'I' },
83  { name: 'Saturno', pantheon: 'romani', domain: 'o tempo', sigil: 'S' },
84  { name: 'Plutão', pantheon: 'romani', domain: 'o submundo', sigil: 'P' },
85  { name: 'Aurora', pantheon: 'romani', domain: 'a alvorada', sigil: 'A' },
86  { name: 'Fortuna', pantheon: 'romani', domain: 'a sorte', sigil: 'F' },
87  { name: 'Vitória', pantheon: 'romani', domain: 'a vitória', sigil: 'V' },
88  { name: 'Justitia', pantheon: 'romani', domain: 'a justiça', sigil: 'I' },
89  { name: 'Concórdia', pantheon: 'romani', domain: 'a harmonia', sigil: 'C' },
90  { name: 'Luna', pantheon: 'romani', domain: 'a lua', sigil: 'L' },
91] as const
92
93export const GODS: readonly string[] = PANTHEON.map(g => g.name)
94
95/** Gods whose domain fits the kind of task, tried before the rest. */
96const AFFINITY: readonly [RegExp, readonly string[]][] = [
97  [/explor|search|find|busca|investig|pesquis|research/i, ['Odin', 'Heimdall', 'Mimir', 'Mercúrio', 'Diana', 'Jano', 'Hermod']],
98  [/plan|design|arquitet|strateg/i, ['Minerva', 'Frigg', 'Odin', 'Júpiter', 'Saturno', 'Forseti']],
99  [/review|revis|audit|verif|check/i, ['Tyr', 'Justitia', 'Forseti', 'Heimdall', 'Vidar']],
100  [/test|qa\b|e2e/i, ['Vulcano', 'Ullr', 'Diana', 'Skadi']],
101  [/fix|bug|debug|corrig/i, ['Thor', 'Marte', 'Eir', 'Vulcano']],
102  [/doc|readme|write|escrev/i, ['Bragi', 'Apolo', 'Mimir']],
103  [/refactor|clean|limp|migra/i, ['Idun', 'Vesta', 'Njord', 'Ceres']],
104  [/deploy|release|ship|build/i, ['Mercúrio', 'Hermod', 'Vitória', 'Fortuna']],
105]
106
107export const godOf = (name: string): God => {
108  const base = name.replace(/ [IVXLC]+$/, '')
109  return PANTHEON.find(g => g.name === base) ?? { name, pantheon: 'aesir', domain: '', sigil: 'othala' }
110}
111
112export type PantheonChoice = 'both' | 'norse' | 'roman'
113
114export function pickGod(used: readonly string[], seed: number, hint = '', only: PantheonChoice = 'both'): string {
115  const allowed = PANTHEON.filter(g => only === 'both' || (only === 'roman') === (g.pantheon === 'romani')).map(g => g.name)
116  const free = allowed.filter(g => !used.includes(g))
117  for (const [re, names] of AFFINITY) {
118    if (!re.test(hint)) continue
119    const fit = names.filter(n => free.includes(n))
120    if (fit.length > 0) return fit[Math.abs(seed) % fit.length] ?? fit[0]!
121  }
122  const pool = free.length > 0 ? free : allowed
123  const base = pool[Math.abs(seed) % pool.length] ?? 'Odin'
124  if (free.length > 0) return base
125  const round = 1 + used.filter(u => u === base || u.startsWith(`${base} `)).length
126  return `${base} ${roman(round)}`
127}
128
129// --- words and numbers
130
131/** Roman numerals; nulla (N) for zero, as the computists wrote it. */
132export function roman(n: number): string {
133  if (n <= 0) return 'N'
134  const map: [number, string][] = [[1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'], [100, 'C'], [90, 'XC'], [50, 'L'], [40, 'XL'], [10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I']]
135  let out = ''
136  for (const [v, s] of map) while (n >= v) { out += s; n -= v }
137  return out
138}
139
140export const clip = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
141
142const esc = (s: string): string =>
143  s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
144
145export const hhmm = (ms: number): string => {
146  const d = new Date(ms)
147  return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`
148}
149
150export const elapsed = (from: number, to: number): string => {
151  const s = Math.max(0, Math.round((to - from) / 1000))
152  if (s < 60) return `${s}s`
153  const m = Math.floor(s / 60)
154  return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
155}
156
157export const progressOf = (w: Worker): number => {
158  if (w.status !== 'running') return 1
159  if (!w.stepTotal) return 0
160  return Math.min(1, (w.stepDone ?? 0) / w.stepTotal)
161}
162
163export const pantheonLabel = (p: Pantheon): string => (p === 'romani' ? 'DI ROMANI' : p === 'vanir' ? 'VANIR' : 'ÆSIR')
164
165// --- palette: soot, carved stone, gold leaf, frost steel, imperial crimson
166
167export const C = {
168  bg: '#13100d',
169  stone: '#1c1814',
170  stoneHi: '#262019',
171  line: '#3b3127',
172  parchment: '#ebe0c6',
173  muted: '#a99c82',
174  dim: '#6e6454',
175  gold: '#c9a24a',
176  goldHi: '#ecd28a',
177  frost: '#9cc0d6',
178  iron: '#56636c',
179  crimson: '#a5322b',
180  purple: '#7a3b6a',
181  laurel: '#86a85e',
182  fallen: '#b5483d',
183  ember: '#dc9a3c',
184} as const
185
186const SERIF = "Cinzel, 'Trajan Pro', 'Cormorant SC', Georgia, 'Times New Roman', serif"
187const BOOK = "'Cormorant Garamond', 'EB Garamond', Georgia, 'Times New Roman', serif"
188
189export const accentOf = (god: God): string => (god.pantheon === 'romani' ? C.gold : god.pantheon === 'vanir' ? C.laurel : C.frost)
190
191// --- small pieces
192
193function rune(key: string, x: number, y: number, scale: number, color: string, width = 1.6): string {
194  const r = RUNES[key] ?? RUNES.othala!
195  return `<path transform="translate(${x} ${y}) scale(${scale})" d="${r.d}" fill="none" stroke="${color}" stroke-width="${width / scale}" stroke-linecap="square" stroke-linejoin="miter"/>`
196}
197
198/** A row of runes, as cut along the rim of a stone. */
199function runeBand(x: number, y: number, width: number, color: string, scale = 0.7, gap = 13): string {
200  const out: string[] = []
201  for (let i = 0, cx = x; cx < x + width - 8; i++, cx += gap) {
202    out.push(rune(RUNE_ORDER[i % RUNE_ORDER.length]!, cx, y, scale, color, 1.1))
203    if (i % 6 === 5) {
204      cx += gap
205      out.push(`<circle cx="${cx + 3.5}" cy="${y + 5.6}" r="1.4" fill="${color}"/>`, `<circle cx="${cx + 3.5}" cy="${y + 1.6}" r="1.4" fill="${color}"/>`)
206    }
207  }
208  return out.join('')
209}
210
211/** A sprig of laurel: leaves along an arc from angle a0 to a1 (radians), mirrored or not. */
212function laurel(cx: number, cy: number, r: number, a0: number, a1: number, color: string, leaves = 9, size = 9): string {
213  const out: string[] = [`<path d="M${(cx + r * Math.cos(a0)).toFixed(1)} ${(cy + r * Math.sin(a0)).toFixed(1)} A${r} ${r} 0 0 ${a1 > a0 ? 1 : 0} ${(cx + r * Math.cos(a1)).toFixed(1)} ${(cy + r * Math.sin(a1)).toFixed(1)}" fill="none" stroke="${color}" stroke-width="1.2"/>`]
214  for (let i = 0; i < leaves; i++) {
215    const a = a0 + ((a1 - a0) * (i + 0.5)) / leaves
216    const x = cx + r * Math.cos(a)
217    const y = cy + r * Math.sin(a)
218    const deg = (a * 180) / Math.PI + (a1 > a0 ? 90 : -90)
219    for (const side of [-1, 1]) {
220      out.push(`<ellipse cx="0" cy="${(-size / 2).toFixed(1)}" rx="${(size / 3.6).toFixed(1)}" ry="${(size / 2).toFixed(1)}" fill="${color}" transform="translate(${x.toFixed(1)} ${y.toFixed(1)}) rotate(${(deg + side * 38).toFixed(0)})"/>`)
221    }
222  }
223  return out.join('')
224}
225
226/** A Norse round shield: planks, iron rim, rivets, a boss. */
227function roundShield(cx: number, cy: number, r: number, wood: string, rim: string, boss: string): string {
228  const planks = Array.from({ length: 5 }, (_, i) => {
229    const x = cx - r + ((i + 1) * 2 * r) / 6
230    const h = Math.sqrt(Math.max(0, r * r - (x - cx) ** 2))
231    return `<line x1="${x.toFixed(1)}" y1="${(cy - h).toFixed(1)}" x2="${x.toFixed(1)}" y2="${(cy + h).toFixed(1)}" stroke="#00000055" stroke-width="1.2"/>`
232  }).join('')
233  const rivets = Array.from({ length: 12 }, (_, i) => {
234    const a = (i / 12) * Math.PI * 2
235    return `<circle cx="${(cx + (r - 3) * Math.cos(a)).toFixed(1)}" cy="${(cy + (r - 3) * Math.sin(a)).toFixed(1)}" r="1.3" fill="${C.goldHi}"/>`
236  }).join('')
237  return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${wood}"/>${planks}` +
238    `<circle cx="${cx}" cy="${cy}" r="${r - 1.5}" fill="none" stroke="${rim}" stroke-width="3.5"/>${rivets}` +
239    (boss === 'none' ? '' : `<circle cx="${cx}" cy="${cy}" r="${r * 0.36}" fill="${boss}" stroke="${C.goldHi}" stroke-width="1.2"/>`)
240}
241
242/** A Roman scutum: tall, curved, with a gold border and boss. */
243function scutum(cx: number, cy: number, w: number, h: number, field: string): string {
244  const x = cx - w / 2, y = cy - h / 2
245  const k = w * 0.16
246  const body = `M${x + k} ${y} Q${cx} ${y - 5} ${x + w - k} ${y} Q${x + w + 3} ${cy} ${x + w - k} ${y + h} Q${cx} ${y + h + 5} ${x + k} ${y + h} Q${x - 3} ${cy} ${x + k} ${y} Z`
247  return `<path d="${body}" fill="${field}" stroke="${C.gold}" stroke-width="2.2"/>` +
248    `<path d="${body}" fill="none" stroke="${C.goldHi}" stroke-width="0.8" transform="translate(${cx} ${cy}) scale(0.84) translate(${-cx} ${-cy})"/>` +
249    `<line x1="${cx}" y1="${y + 6}" x2="${cx}" y2="${y + h - 6}" stroke="${C.gold}" stroke-width="1.2" opacity="0.7"/>`
250}
251
252/** The god's emblem: a round shield with its rune, or a scutum with its initial and laurel. */
253function emblem(god: God, cx: number, cy: number, size: number, status: Worker['status']): string {
254  const mark = status === 'running' ? C.ember : status === 'done' ? C.laurel : C.fallen
255  const pip = `<path d="M${cx + size * 0.62} ${cy + size * 0.5}l5 -5l5 5l-5 5z" fill="${mark}" stroke="${C.bg}" stroke-width="1.5"/>`
256  if (god.pantheon === 'romani') {
257    return laurel(cx, cy, size * 0.82, Math.PI * 0.62, Math.PI * 1.38, C.laurel, 5, size * 0.26) +
258      laurel(cx, cy, size * 0.82, Math.PI * 0.38, -Math.PI * 0.38, C.laurel, 5, size * 0.26) +
259      scutum(cx, cy, size * 0.9, size * 1.2, god.name === 'Vênus' || god.name === 'Juno' ? C.purple : C.crimson) +
260      `<circle cx="${cx}" cy="${cy}" r="${size * 0.22}" fill="${C.stone}" stroke="${C.gold}" stroke-width="1.4"/>` +
261      `<text x="${cx}" y="${cy + size * 0.12}" text-anchor="middle" fill="${C.goldHi}" font-family="${SERIF}" font-size="${size * 0.32}" font-weight="700">${esc(god.sigil)}</text>` + pip
262  }
263  const r = size * 0.62
264  const s = (r * 1.15) / 16
265  return roundShield(cx, cy, r, god.pantheon === 'vanir' ? '#3a4a2c' : '#2c3a44', C.iron, 'none') +
266    `<circle cx="${cx}" cy="${cy}" r="${r * 0.72}" fill="#0000004d"/>` +
267    rune(god.sigil, cx - 5 * s, cy - 8 * s, s, accentOf(god), 2.6) + pip
268}
269
270/** Progress as notches cut in a stone: filled ones gilded, Roman milestones every fifth. */
271function notches(x: number, y: number, p: number, color: string, isRoman: boolean): string {
272  const filled = Math.round(p * 20)
273  const out: string[] = [`<rect x="${x - 4}" y="${y - 4}" width="${20 * 13 + 4}" height="18" rx="2" fill="${C.bg}" stroke="${C.line}"/>`]
274  for (let i = 0; i < 20; i++) {
275    const nx = x + i * 13
276    out.push(i < filled
277      ? `<path d="M${nx} ${y}h9l-2 10h-5z" fill="${color}"/>`
278      : `<path d="M${nx} ${y}h9l-2 10h-5z" fill="none" stroke="${C.line}"/>`)
279    if (isRoman && (i + 1) % 5 === 0) {
280      out.push(`<text x="${nx + 4.5}" y="${y + 24}" text-anchor="middle" fill="${C.dim}" font-family="${SERIF}" font-size="8">${roman(i + 1)}</text>`)
281    }
282  }
283  return out.join('')
284}
285
286const frame = (w: number, h: number, inner = true): string =>
287  `<defs><linearGradient id="st" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${C.stoneHi}"/><stop offset="1" stop-color="${C.stone}"/></linearGradient></defs>` +
288  `<rect width="${w}" height="${h}" rx="6" fill="url(#st)"/>` +
289  `<rect x="1" y="1" width="${w - 2}" height="${h - 2}" rx="5" fill="none" stroke="${C.line}" stroke-width="2"/>` +
290  (inner ? `<rect x="6" y="6" width="${w - 12}" height="${h - 12}" rx="3" fill="none" stroke="${C.gold}" stroke-width="0.8" opacity="0.55"/>` : '')
291
292const corners = (w: number, h: number): string =>
293  [[10, 10], [w - 10, 10], [10, h - 10], [w - 10, h - 10]]
294    .map(([x, y]) => `<path d="M${x} ${y! - 4}l4 4l-4 4l-4 -4z" fill="${C.gold}"/>`).join('')
295
296// --- the pane's drawings
297
298export function heroSvg(project: string, list: readonly Worker[], t: number): string {
299  const W = 920, H = 200
300  const running = list.filter(w => w.status === 'running')
301  const done = list.filter(w => w.status === 'done').length
302  const fallen = list.filter(w => w.status === 'failed').length
303  const saga = running.length
304    ? `${running.map(w => w.god).join(', ')} ${running.length === 1 ? 'luta' : 'lutam'} por você agora.`
305    : done ? `${roman(done)} triunfo${done === 1 ? '' : 's'} nesta jornada. O salão descansa.` : 'O salão aguarda. Convoque um subagente e um deus atende.'
306  const stat = (x: number, label: string, n: number, color: string) =>
307    `<rect x="${x}" y="134" width="150" height="40" rx="3" fill="${C.bg}" stroke="${color}" stroke-width="1.2"/>` +
308    `<rect x="${x + 3}" y="137" width="144" height="34" rx="2" fill="none" stroke="${color}" stroke-width="0.5" opacity="0.5"/>` +
309    `<text x="${x + 14}" y="160" fill="${color}" font-family="${SERIF}" font-size="17" font-weight="700">${roman(n)}</text>` +
310    `<text x="${x + 136}" y="159" text-anchor="end" fill="${C.muted}" font-family="${SERIF}" font-size="10" letter-spacing="1.5">${label}</text>`
311  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">
312${frame(W, H)}${corners(W, H)}
313${runeBand(30, 14, W - 60, C.gold, 0.55, 11)}
314<line x1="20" y1="30" x2="${W - 20}" y2="30" stroke="${C.line}"/>
315${laurel(105, 110, 66, Math.PI * 0.58, Math.PI * 1.42, C.laurel, 9, 13)}
316${laurel(105, 110, 66, Math.PI * 0.42, -Math.PI * 0.42, C.laurel, 9, 13)}
317${roundShield(105, 110, 52, '#2c3a44', C.iron, C.stone)}
318<text x="105" y="117" text-anchor="middle" fill="${C.goldHi}" font-family="${SERIF}" font-size="${running.length > 3 ? 15 : 20}" font-weight="700">${roman(running.length)}</text>
319<text x="200" y="58" fill="${C.gold}" font-family="${SERIF}" font-size="11" letter-spacing="3">SALA·DEORVM · ${esc(project.toUpperCase())} · ${hhmm(t)} UTC</text>
320<text x="200" y="96" fill="${C.goldHi}" font-family="${SERIF}" font-size="34" font-weight="700" letter-spacing="1">Salão dos Deuses</text>
321<text x="200" y="120" fill="${C.parchment}" font-family="${BOOK}" font-size="16" font-style="italic">${esc(clip(saga, 80))}</text>
322${stat(200, 'TRIUNFOS', done, C.laurel)}${stat(362, 'EM BATALHA', running.length, C.ember)}${stat(524, 'TOMBADOS', fallen, C.fallen)}
323<text x="${W - 34}" y="70" text-anchor="end" fill="${C.dim}" font-family="${SERIF}" font-size="10" letter-spacing="3">ANNO</text>
324<text x="${W - 34}" y="100" text-anchor="end" fill="${C.gold}" font-family="${SERIF}" font-size="26" font-weight="700" opacity="0.75">${roman(new Date(t).getUTCFullYear())}</text>
325${rune('othala', W - 70, 118, 2, C.gold, 1.6)}
326<text x="${W - 30}" y="186" text-anchor="end" fill="${C.dim}" font-family="${SERIF}" font-size="9" letter-spacing="3">OPERA · DEORVM · ÆSIR · VANIR · DI ROMANI</text>
327</svg>`
328}
329
330export function bannerSvg(title: string, sub = ''): string {
331  const W = 920, H = 34
332  const tw = title.length * 15 + 36
333  const l = W / 2 - tw / 2, r = W / 2 + tw / 2
334  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">
335<line x1="16" y1="17" x2="${l}" y2="17" stroke="${C.line}" stroke-width="1"/><line x1="${r}" y1="17" x2="${W - 16}" y2="17" stroke="${C.line}" stroke-width="1"/>
336<path d="M${l - 2} 17l-6 -5l-6 5l6 5z" fill="${C.gold}"/><path d="M${r + 2} 17l6 -5l6 5l-6 5z" fill="${C.gold}"/>
337<text x="${W / 2}" y="22" text-anchor="middle" fill="${C.gold}" font-family="${SERIF}" font-size="15" font-weight="700" letter-spacing="4">${esc(title)}</text>
338${sub ? `<text x="${W - 16}" y="12" text-anchor="end" fill="${C.dim}" font-family="${SERIF}" font-size="9" letter-spacing="2">${esc(sub)}</text>` : ''}
339</svg>`
340}
341
342const OMEN = {
343  ok: { color: C.laurel, word: 'FAVORÁVEL', rune: 'sowilo' },
344  warn: { color: C.fallen, word: 'MAU PRESSÁGIO', rune: 'naudiz' },
345  wait: { color: C.ember, word: 'AGUARDANDO', rune: 'isa' },
346} as const
347
348export function oracleSvg(lines: readonly Check[], at: number): string {
349  const W = 920, row = 26, H = 50 + Math.max(1, lines.length) * row
350  const body = lines.map((l, i) => {
351    const o = OMEN[l.level]
352    const y = 44 + i * row
353    return rune(o.rune, 30, y - 12, 0.9, o.color, 1.6) +
354      `<text x="52" y="${y}" fill="${o.color}" font-family="${SERIF}" font-size="10" letter-spacing="1.5">${o.word}</text>` +
355      `<text x="180" y="${y}" fill="${C.parchment}" font-family="${BOOK}" font-size="15">${esc(clip(l.text, 92))}</text>`
356  }).join('')
357  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">
358${frame(W, H, false)}
359<text x="22" y="22" fill="${C.gold}" font-family="${SERIF}" font-size="11" letter-spacing="3">O ORÁCULO FALA · ${hhmm(at)} UTC</text>
360<line x1="22" y1="28" x2="${W - 22}" y2="28" stroke="${C.line}"/>
361${body}
362</svg>`
363}
364
365export function workerCardSvg(w: Worker, t: number): string {
366  const god = godOf(w.god)
367  const isRoman = god.pantheon === 'romani'
368  const accent = accentOf(god)
369  const W = 452, H = 176
370  const step = w.stepTotal ? `${roman(w.stepDone ?? 0)} / ${roman(w.stepTotal)}` : '—'
371  const note = w.stepNote ? clip(w.stepNote, 44) : w.status === 'running' ? (isRoman ? 'marchando para a frente…' : 'afiando o machado…') : ''
372  const sagaLabel = isRoman ? 'ÉDITO' : 'SAGA'
373  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">
374${frame(W, H)}
375${isRoman ? `<rect x="6" y="6" width="${W - 12}" height="4" fill="${C.crimson}" opacity="0.85"/>` : runeBand(18, 9, W - 36, C.iron, 0.4, 9)}
376${emblem(god, 56, 70, 46, w.status)}
377<text x="110" y="50" fill="${C.parchment}" font-family="${SERIF}" font-size="21" font-weight="700" letter-spacing="1.5">${esc(w.god.toUpperCase())}</text>
378<text x="110" y="68" fill="${accent}" font-family="${BOOK}" font-size="14" font-style="italic">${esc(god.domain ? (isRoman ? `deus de ${god.domain}` : god.domain) : '')}</text>
379<text x="${W - 18}" y="34" text-anchor="end" fill="${C.dim}" font-family="${SERIF}" font-size="9" letter-spacing="2">${pantheonLabel(god.pantheon)}</text>
380<text x="${W - 18}" y="50" text-anchor="end" fill="${C.dim}" font-family="${SERIF}" font-size="10">${esc(w.type)} · ${elapsed(w.startedAt, w.endedAt ?? t)}</text>
381<text x="110" y="92" fill="${C.goldHi}" font-family="${BOOK}" font-size="15" font-weight="600">${esc(clip(w.topic, 44))}</text>
382${notches(112, 104, progressOf(w), accent, isRoman)}
383<text x="${W - 18}" y="113" text-anchor="end" fill="${C.muted}" font-family="${SERIF}" font-size="11">${step}</text>
384<text x="110" y="${isRoman ? 140 : 134}" fill="${C.muted}" font-family="${BOOK}" font-size="13" font-style="italic">${esc(note)}</text>
385<line x1="18" y1="148" x2="${W - 18}" y2="148" stroke="${C.line}"/>
386<text x="20" y="166" fill="${C.gold}" font-family="${SERIF}" font-size="9" letter-spacing="2">${sagaLabel}</text>
387<text x="66" y="166" fill="${C.parchment}" font-family="${BOOK}" font-size="14">${esc(clip(w.tldr ?? '— ainda não cantada —', 58))}</text>
388</svg>`
389}
390
391export function doneCardSvg(w: Worker): string {
392  const god = godOf(w.god)
393  const ok = w.status === 'done'
394  const isRoman = god.pantheon === 'romani'
395  const color = ok ? C.laurel : C.fallen
396  const verdict = ok ? (isRoman ? 'TRIUMPHUS' : 'VALHALLA') : 'TOMBOU'
397  const W = 296, H = 78
398  const mark = isRoman
399    ? `<text x="30" y="47" text-anchor="middle" fill="${C.goldHi}" font-family="${SERIF}" font-size="20" font-weight="700">${esc(god.sigil)}</text>${laurel(30, 40, 19, Math.PI * 0.6, Math.PI * 1.4, ok ? C.laurel : C.dim, 4, 7)}${laurel(30, 40, 19, Math.PI * 0.4, -Math.PI * 0.4, ok ? C.laurel : C.dim, 4, 7)}`
400    : rune(god.sigil, 30 - 5 * 1.4, 40 - 8 * 1.4, 1.4, ok ? accentOf(god) : C.fallen, 2.2)
401  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">
402${frame(W, H, false)}<rect x="2" y="2" width="3" height="${H - 4}" fill="${color}"/>
403${mark}
404<text x="58" y="30" fill="${C.parchment}" font-family="${SERIF}" font-size="14" font-weight="700" letter-spacing="1">${esc(w.god.toUpperCase())}</text>
405<text x="${W - 12}" y="29" text-anchor="end" fill="${color}" font-family="${SERIF}" font-size="9" letter-spacing="2">${verdict}</text>
406<text x="58" y="50" fill="${C.goldHi}" font-family="${BOOK}" font-size="13">${esc(clip(w.topic, 34))}</text>
407<text x="58" y="67" fill="${C.muted}" font-family="${BOOK}" font-size="12" font-style="italic">${esc(clip(w.tldr ?? '', 40))}</text>
408</svg>`
409}
410
411/** The terminal's sigil for a god: its rune, or its initial between Roman points. */
412export const glyphOf = (god: God): string => (god.pantheon === 'romani' ? `·${god.sigil}·` : (RUNES[god.sigil]?.char ?? 'ᛟ'))
413
types/index.d.ts 56 lines
1export type WorkerStatus = 'running' | 'done' | 'failed'
2
3export type Worker = {
4  id: string
5  agentId?: string
6  god: string
7  topic: string
8  type: string
9  status: WorkerStatus
10  startedAt: number
11  endedAt?: number
12  stepDone?: number
13  stepTotal?: number
14  stepNote?: string
15  tldr?: string
16}
17
18export type Check = { level: 'ok' | 'warn' | 'wait'; text: string }
19
20export type Verification = { at: number; lines: Check[] }
21
22// The /mods hub protocol (github.com/mvacoimbra/claude-mods-hub, PROTOCOL.md): one tab, drawn by the hub.
23export type WorkersLang = 'en' | 'pt'
24export type WorkersPanelArgs = { lang: WorkersLang; surface: 'terminal' | 'desktop' | 'vscode' | 'mobile'; columns: number }
25export type WorkersTone = 'ok' | 'warn' | 'err' | 'muted' | 'accent'
26export type WorkersItem =
27  | { kind: 'toggle'; id: string; label: string; help?: string; value: boolean }
28  | { kind: 'choice'; id: string; label: string; help?: string; value: string; options: { value: string; label: string }[] }
29  | { kind: 'action'; id: string; label: string; help?: string; primary?: boolean }
30  | { kind: 'input'; id: string; label: string; help?: string; placeholder?: string; submitLabel?: string }
31  | { kind: 'info'; label: string; value: string; tone?: WorkersTone }
32  | { kind: 'text'; text: string; tone?: WorkersTone }
33  | { kind: 'swatch'; id: string; label: string; color: string; selected: boolean }
34  | { kind: 'tree'; tree: unknown }
35export type WorkersSection = { id: string; title: string; items: WorkersItem[] }
36export type WorkersPanel = { title: string; summary?: string; tone?: WorkersTone; sections: WorkersSection[]; refreshMs?: number }
37export type WorkersActArgs = { id: string; value?: string }
38export type WorkersActResult = { message?: string }
39
40declare module 'claude-code' {
41  interface EngineInterface {
42    workers: {
43      panel: (args: WorkersPanelArgs) => Promise<WorkersPanel>
44      act: (args: WorkersActArgs) => Promise<WorkersActResult>
45    }
46  }
47  interface PluginState {
48    workers: {
49      workers: Worker[]
50      verification: Verification | null
51      autoOpened: boolean
52      now: number
53    }
54  }
55}
56