SLOPSHOPPER

agent-monitor

Live pane of subagents with a Clawd per agent: status, model, tokens, cost and context

newpaneguardcommandtimeragents
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-monitor
│ ┃ Agentes ✕ › fix the failing auth test and add an audit log call │ ┃ ✻ Agentes 0 en el panel │ ┃ ● 0 trabajando ⏺ Read(src/auth.ts) │ ┃ 0 tokens · ~US$ 0.000 en la sesión ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ▐▛███▜▌ ⎿ 3 pass, 1 fail │ ┃ ▝▜█████▛▘ │ ┃ ▘▘ ▝▝ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Todavía no hay subagentes. │ ┃ ✻ Worked for 42s · done 4:20 PM │ │ › /agent-monitor │ ⎿ agent-monitor: Panel de agentes abierto. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agentes
✻ Agentes 0 en el panel ● 0 trabajando 0 tokens · ~US$ 0.000 en la sesión ▐▛███▜▌ ▝▜█████▛▘ ▘▘ ▝▝ Todavía no hay subagentes.
README

agent-monitor

Panel en vivo para Claude Code que muestra tus subagentes. Cada uno es un Clawd, la mascota de Claude Code:

 ▐▛███▜▌
▝▜█████▛▘
  ▘▘ ▝▝

Cada tarjeta muestra:

  • Estado: trabajando, espera tu respuesta (! que titila), terminó (duerme, con una z) o falló (gris). Un agente que terminó se oculta si en 30 segundos no recibe otra instrucción; la tarjeta muestra la cuenta regresiva.
  • Modelo: por ejemplo «Sonnet 5.5».
  • Tokens y costo estimado en US$.
  • Barra de contexto: verde, amarilla o roja según lo lleno que esté.

Skins

El skin de Clawd cambia en vivo según lo que hace el agente:

SkinCuándoAnimación
research · investigandoSus últimas herramientas son sobre todo de búsqueda o lectura (Read, Grep, Glob, WebSearch, WebFetch)Explorador que marcha con una bandera que flamea; al terminar, la bandera cae
developer · programandoSus últimas herramientas son sobre todo de escritura (Edit, Write) o comandos (Bash)Audífonos y laptop: teclea con un brazo y luego con el otro, y el código corre en pantalla
auditor · auditandoSu tipo o su tarea habla de revisar, auditar, seguridad o QAGafas con un barrido rojo y un portapapeles que se va marcando
baseAntes de su primera herramientaCamina y parpadea

Modelos

Cuanto más caro el modelo, más destaca, sin tocar el borde de la tarjeta (que es del estado):

ModeloEtiquetaClawd
Haikugris tenuenormal
Sonnet● Sonnet con punto azulnormal
Opus◆ Opus violeta en negritados chispas que titilan
Fable✦ Fable dorado en negritahalo dorado que late y chispas

Si hay Opus o Fable trabajando, el encabezado los cuenta: ◆ 1 Opus · ✦ 1 Fable activos.

La ventana de contexto y el costo salen de hooks/models.ts, con los precios de lista de la API de Anthropic (Bedrock y Vertex cobran aparte, así que el costo es estimado). Los modelos actuales tienen 1M de contexto; Haiku 4.5 y los anteriores, 200k.

Para ver todos los skins en todos los estados, genera la vista previa y abre docs/catalogo.html:

node scripts/catalogo.ts

Funciona en la terminal y en la app de escritorio (pestaña Code).

Instalar

claude plugin marketplace add jorgehsy/claude-mods
claude plugin install agent-monitor@jorge-mods --scope user

Desde ahí carga en todas tus sesiones, en la terminal y en la app de escritorio.

Usar

  • Terminal: escribe /agent-monitor. Con una ventana de 144 columnas o más, el panel se abre solo con el primer subagente.
  • App de escritorio: el panel se abre solo cuando arranca el primer subagente.

Actualizar

claude plugin marketplace update jorge-mods
claude plugin update agent-monitor@jorge-mods

Después escribe /reload-plugins en la sesión abierta.

Notas

  • Los costos son estimados con precios de lista por modelo; no son tu factura (con un plan de suscripción, no pagas por token).
  • Haiku 5.5 cobra 5× por encima de 100k tokens de prompt; el panel lo tiene en cuenta.

Licencia

MIT. Ver LICENSE.

Source 4 files
hooks/register.tsx 336 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRow } from '../types'
5import { SKINS, svgOf, cellsOf, withTier, COLS, ROWS, type Mood, type Role } from './skins.ts'
6import { modelInfo, costOf, tierOf, TIER_STYLE, type Tier } from './models.ts'
7
8const PANE = 'agent-monitor'
9const rows = atom({ plugin: 'agent-monitor', key: 'rows' } as const, {})
10const frame = atom({ plugin: 'agent-monitor', key: 'frame' } as const, 0)
11const opened = atom({ plugin: 'agent-monitor', key: 'opened' } as const, false)
12// La hora del panel, al segundo: mueve la cuenta regresiva de los que terminaron.
13const now = atom({ plugin: 'agent-monitor', key: 'now' } as const, 0)
14
15// Cada tick del reloj del panel dura TICK ms; los cuadros de cada skin se
16// reparten según sus propios fps.
17const TICK = 125
18// Un agente que terminó y no recibe otra instrucción en este tiempo se oculta.
19const LINGER = 30_000
20
21// ── Precio y ventana: ver models.ts ─────────────────────────────────────────
22const k = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
23
24function prettyModel(model: string) {
25  if (!model || model === '-') return '—'
26  const m = model.replace(/^.*claude-/, '').replace(/\[.*\]$/, '')
27  const [fam = '', ...v] = m.split('-')
28  return `${fam.charAt(0).toUpperCase()}${fam.slice(1)} ${v.filter(x => /^\d+$/.test(x)).join('.')}`.trim()
29}
30
31// ── Estados ──────────────────────────────────────────────────────────────────
32const moodOf = (s: string): Mood =>
33  s === 'waiting' ? 'wait' : s === 'failed' || s === 'killed' ? 'dead'
34    : s === 'running' || s === 'pending' ? 'work' : 'sleep'
35const LABEL: Record<string, string> = {
36  running: 'trabajando', pending: 'arrancando', waiting: 'espera tu respuesta',
37  idle: 'inactivo', completed: 'terminó', failed: 'falló', killed: 'detenido',
38}
39const STATUS_COLOR: Record<Mood, string> = { work: '#4EBA65', wait: '#E5B93C', sleep: '#8A8A8A', dead: '#E5534B' }
40
41// ── Qué está haciendo: el skin ───────────────────────────────────────────────
42const AUDIT = /review|audit|security|seguridad|vuln|qa\b|checker|reviewer|revis|auditor|evidence/i
43const WRITES = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash'])
44const READS = new Set(['Read', 'Grep', 'Glob', 'LS', 'WebSearch', 'WebFetch'])
45const RECENT = 6
46
47type Kind = 'read' | 'write' | 'other'
48
49/**
50 * Qué fue una llamada. El motor marca `isReadOnly` con el mismo chequeo de sus
51 * permisos: un `ls` o un `git diff` por Bash es lectura, un `pnpm build` no.
52 */
53function kindOf(tool: string, isReadOnly: boolean | undefined): Kind {
54  if (isReadOnly || READS.has(tool) || tool.startsWith('mcp__')) return 'read'
55  if (WRITES.has(tool)) return 'write'
56  return 'other'
57}
58
59/** auditor por su encargo; si no, lo que dominan sus últimas llamadas. */
60function roleOf(r: AgentRow): Role {
61  if (AUDIT.test(`${r.type} ${r.description}`)) return 'auditor'
62  const kinds = (r.tools ?? []).filter(t => t === 'read' || t === 'write')
63  if (kinds.length === 0) return /explore|plan|research|buscador/i.test(r.type) ? 'research' : 'base'
64  const writes = kinds.filter(t => t === 'write').length
65  return writes > kinds.length - writes ? 'developer' : 'research'
66}
67
68// ── Datos ────────────────────────────────────────────────────────────────────
69const blank = (id: string): AgentRow => ({
70  id, type: '?', description: '', status: 'running', model: '-',
71  input: 0, output: 0, cacheRead: 0, cacheWrite: 0, context: 0, usd: 0, tools: [],
72})
73
74const isLive = (status: string) => {
75  const m = moodOf(status)
76  return m === 'work' || m === 'wait'
77}
78
79async function sync($: EngineInterface) {
80  const list = await $.agent.list()
81  const t = await $.clock.now()
82  await update($, rows, (all: Record<string, AgentRow>) => {
83    const next = { ...all }
84    for (const a of list) {
85      const prev = next[a.id] ?? blank(a.id)
86      const live = isLive(String(a.status))
87      // Al terminar arranca la cuenta; si vuelve a trabajar, se cancela.
88      const endedAt = live ? undefined : prev.endedAt ?? t
89      next[a.id] = { ...prev, type: a.type, description: a.description, status: a.status, endedAt }
90    }
91    return next
92  })
93}
94
95async function openOnce($: EngineInterface) {
96  if (await read($, opened)) return
97  await update($, opened, () => true)
98  await $.ui.open({ id: PANE, title: 'Agentes' })
99}
100
101function b64(words: Uint32Array) {
102  const bytes = new Uint8Array(words.buffer)
103  let s = ''
104  for (const b of bytes) s += String.fromCharCode(b)
105  return btoa(s)
106}
107
108const ctxColor = (r: number) => (r < 0.6 ? '#4EBA65' : r < 0.85 ? '#E5B93C' : '#E5534B')
109
110function barSvg(ratio: number, color: string): string {
111  const w = Number.isFinite(ratio) ? Math.max(0, Math.min(1, ratio)) * 100 : 0
112  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 6" width="220" height="8" preserveAspectRatio="none"><rect width="100" height="6" rx="3" fill="#8A8A8A" opacity=".2"/><rect width="${w.toFixed(2)}" height="6" rx="3" fill="${color}"/></svg>`
113}
114
115export const register: Register = on => {
116  on('session.start', async ($, e, next) => {
117    await $.command.register({ name: 'agent-monitor', description: 'Abre el panel de subagentes' })
118    $.clock.every(TICK, async () => {
119      const all = Object.values(await read($, rows)) as AgentRow[]
120      if (all.some(r => moodOf(r.status) === 'work' || moodOf(r.status) === 'wait')) {
121        await update($, frame, (n: number) => n + 1)
122      }
123    })
124    // Cada segundo: estado al día, cuenta regresiva y ocultar a los que se cumplió.
125    $.clock.every(1000, async () => {
126      const all = Object.values(await read($, rows)) as AgentRow[]
127      const t = await $.clock.now()
128      if (all.some(r => r.endedAt === undefined || t - r.endedAt < LINGER + 1000)) {
129        await sync($)
130        // Los que ya no lista el motor también cuentan desde ahora.
131        await update($, rows, (cur: Record<string, AgentRow>) => {
132          const out = { ...cur }
133          for (const [id, r] of Object.entries(out)) {
134            if (!isLive(r.status) && r.endedAt === undefined) out[id] = { ...r, endedAt: t }
135          }
136          return out
137        })
138        await update($, now, () => t)
139      }
140    })
141    return next(e)
142  })
143
144  on('command.run', { command: 'agent-monitor' }, async $ => {
145    await update($, opened, () => true)
146    await $.ui.open({ id: PANE, title: 'Agentes' })
147    return { text: 'Panel de agentes abierto.' }
148  })
149
150  // En el escritorio no hay comandos: el panel se abre solo con el primer subagente.
151  on('agent.spawn', async ($, e, next) => {
152    const r = await next(e)
153    await sync($)
154    await openOnce($)
155    return r
156  })
157
158  // Cada herramienta que usa un subagente decide su skin, ya resuelta.
159  on('tool.call', async ($, e, next) => {
160    const res = await next(e)
161    const id = e.agentId
162    if (id) {
163      try {
164        const kind = kindOf(String(e.tool), (res as { isReadOnly?: boolean }).isReadOnly)
165        await update($, rows, (all: Record<string, AgentRow>) => {
166          const r = all[id] ?? blank(id)
167          return { ...all, [id]: { ...r, tools: [...(r.tools ?? []), kind].slice(-RECENT) } }
168        })
169      } catch (err) {
170        // El panel nunca frena una herramienta; el fallo queda en el log de depuración.
171        $.ui.log(`agent-monitor: no se pudo anotar ${String(e.tool)}: ${String(err)}`, { to: 'debug' })
172      }
173    }
174    return res
175  })
176
177  on('turn.step', async function* ($, e, next) {
178    const res = yield* next(e)
179    const id = e.agentId
180    if (id && res.usage) {
181      const n = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
182      const u = {
183        model: String(res.usage.model ?? '-'),
184        input_tokens: n(res.usage.input_tokens),
185        output_tokens: n(res.usage.output_tokens),
186        cache_read_input_tokens: n(res.usage.cache_read_input_tokens),
187        cache_creation_input_tokens: n(res.usage.cache_creation_input_tokens),
188      }
189      const cost = costOf(u.model, {
190        input: u.input_tokens, output: u.output_tokens,
191        cacheRead: u.cache_read_input_tokens, cacheWrite: u.cache_creation_input_tokens,
192      })
193      await update($, rows, (all: Record<string, AgentRow>) => {
194        const r = all[id] ?? blank(id)
195        return {
196          ...all,
197          [id]: {
198            ...r,
199            model: u.model,
200            input: r.input + u.input_tokens,
201            output: r.output + u.output_tokens,
202            cacheRead: r.cacheRead + u.cache_read_input_tokens,
203            cacheWrite: r.cacheWrite + u.cache_creation_input_tokens,
204            context: u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens,
205            usd: r.usd + cost,
206          },
207        }
208      })
209    }
210    await sync($)
211    return res
212  })
213
214  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
215    const els = $.ui.resolve(e)
216    const { Box, Text } = els
217    const isTerm = e.surface === 'terminal'
218    const every = Object.values(await read($, rows)) as AgentRow[]
219    const t = (await read($, now)) as number
220    const left = (r: AgentRow) => (r.endedAt === undefined ? null : Math.max(0, Math.ceil((LINGER - (t - r.endedAt)) / 1000)))
221    const all = every.filter(r => left(r) !== 0)
222    // Sólo el terminal anima cuadro a cuadro; el SVG del escritorio se anima solo.
223    const tick = isTerm ? ((await read($, frame)) as number) : 0
224    // Tokens y costo: toda la sesión, también los que ya se ocultaron.
225    const total = every.reduce((s, r) => s + r.usd, 0)
226    const tokens = every.reduce((s, r) => s + r.input + r.output + r.cacheRead + r.cacheWrite, 0)
227    const live = all.filter(r => moodOf(r.status) === 'work').length
228    const waiting = all.filter(r => moodOf(r.status) === 'wait').length
229    // Opus y Fable trabajando o esperando: se cuentan arriba para verlos sin bajar.
230    const busy = all.filter(r => moodOf(r.status) === 'work' || moodOf(r.status) === 'wait')
231    const costly = (['top', 'high'] as const)
232      .map(t => [t, busy.filter(r => tierOf(r.model) === t).length] as [Tier, number])
233      .filter(([, n]) => n > 0)
234    const cols = (e.props as any)?.bodyColumns ?? 40
235    const barW = Math.max(10, Math.min(30, cols - 12))
236
237    const mascot = (role: Role, mood: Mood, key: string, tier: Tier = 'mid') => {
238      const anim = withTier(SKINS[role].animate(mood), tier, mood, isTerm ? 'term' : 'svg')
239      if (isTerm && 'Raster' in els) {
240        const { Raster } = els as any
241        const n = anim.frames.length
242        const i = Math.floor((tick * TICK * anim.fps) / 1000) % n
243        return <Raster key={`m-${key}`} columns={COLS} rows={ROWS} cells={b64(cellsOf(anim.frames[i]!))} />
244      }
245      if ('Svg' in els) {
246        const { Svg } = els as any
247        return <Svg source={svgOf(anim, mood, 104)} alt={`Clawd ${SKINS[role].label}`} width={104} height={68} />
248      }
249      return <Text>✳</Text>
250    }
251
252    const ctxBar = (ratio: number) => {
253      const color = ctxColor(ratio)
254      if (!isTerm && 'Svg' in els) {
255        const { Svg } = els as any
256        return <Svg source={barSvg(ratio, color)} alt={`contexto ${Math.round(ratio * 100)}%`} width={220} height={8} />
257      }
258      const full = Math.round(Math.max(0, Math.min(1, ratio)) * barW)
259      return (
260        <Text>
261          <Text color={color}>{'━'.repeat(full)}</Text>
262          <Text dimColor>{'━'.repeat(barW - full)}</Text>
263        </Text>
264      )
265    }
266
267    const order: Record<Mood, number> = { wait: 0, work: 1, dead: 2, sleep: 3 }
268    const sorted = [...all].sort((a, b) => order[moodOf(a.status)] - order[moodOf(b.status)])
269
270    return (
271      <Box flexDirection="column" gap={1}>
272        <Box flexDirection="column" paddingX={1}>
273          <Text>
274            <Text bold color="#D97757">✻ Agentes</Text>
275            <Text dimColor>  {all.length} en el panel</Text>
276          </Text>
277          <Text>
278            <Text color={STATUS_COLOR.work}>● {live} trabajando</Text>
279            {waiting > 0 && <Text color={STATUS_COLOR.wait} bold>   ▲ {waiting} esperan</Text>}
280          </Text>
281          {costly.length > 0 && (
282            <Text>
283              {costly.map(([tier, n], i) => (
284                <Text key={tier} color={TIER_STYLE[tier].color} bold>{i > 0 ? ' · ' : ''}{TIER_STYLE[tier].mark}{n} {tier === 'top' ? 'Fable' : 'Opus'}</Text>
285              ))}
286              <Text dimColor> activos</Text>
287            </Text>
288          )}
289          <Text dimColor>{k(tokens)} tokens · ~US$ {total.toFixed(3)} en la sesión</Text>
290        </Box>
291
292        {all.length === 0 && (
293          <Box flexDirection="column" alignItems="center" paddingY={1}>
294            {mascot('base', 'sleep', 'empty')}
295            <Text dimColor>Todavía no hay subagentes.</Text>
296          </Box>
297        )}
298
299        {sorted.map(r => {
300          const role = roleOf(r)
301          const skin = SKINS[role]
302          const tier = tierOf(r.model)
303          const ts = TIER_STYLE[tier]
304          const mood = moodOf(r.status)
305          const win = modelInfo(r.model).window
306          const ratio = r.context / win
307          const dim = mood === 'sleep'
308          const border = mood === 'wait' ? (tick % 4 < 2 ? STATUS_COLOR.wait : '#D97757') : STATUS_COLOR[mood]
309          return (
310            <Box key={r.id} borderStyle={mood === 'wait' ? 'bold' : 'round'} borderColor={border} paddingX={1} flexDirection="column">
311              <Box flexDirection="row" gap={1}>
312                {mascot(role, mood, r.id, tier)}
313                <Box flexDirection="column" flexGrow={1}>
314                  <Text bold dimColor={dim}>{r.type}</Text>
315                  <Text color={STATUS_COLOR[mood]} bold={mood === 'wait'}>
316                    {mood === 'wait' ? '▲ ' : '● '}{LABEL[r.status] ?? r.status}
317                    {left(r) !== null && <Text dimColor>  · se oculta en {left(r)} s</Text>}
318                  </Text>
319                  {role !== 'base' && (mood === 'work' || mood === 'wait') && <Text color={skin.accent}>◆ {skin.label}</Text>}
320                  <Text color={ts.color} bold={ts.bold} dimColor={ts.dim}>{ts.mark}{prettyModel(r.model)}</Text>
321                </Box>
322              </Box>
323              {r.description !== '' && <Text dimColor={dim} wrap="truncate-end">{r.description}</Text>}
324              <Text dimColor={dim}>
325                ↑ {k(r.input + r.cacheRead + r.cacheWrite)}  ↓ {k(r.output)}  · ~US$ {r.usd.toFixed(3)}
326              </Text>
327              {ctxBar(ratio)}
328              <Text dimColor>contexto {k(r.context)} / {k(win)} · {Math.round(ratio * 100)}%</Text>
329            </Box>
330          )
331        })}
332      </Box>
333    )
334  })
335}
336
hooks/skins.ts 396 lines
1// Catálogo de skins de Clawd. Datos puros, sin dependencias: el panel los
2// pinta en el terminal (Raster, cuartos de bloque) y en el escritorio (SVG).
3//
4// Lienzo de W×H píxeles; cada píxel es 1 de ancho por 2 de alto, como las
5// celdas del logo de Claude Code. Clawd ocupa x 0–17; los accesorios, x 17–25.
6
7import type { Tier } from './models.ts'
8
9export const W = 26
10export const H = 8
11
12export type Px = [number, number, number] // x, y, color 0xRRGGBB
13export type Frame = Px[]
14export type Mood = 'work' | 'wait' | 'sleep' | 'dead'
15export type Role = 'research' | 'developer' | 'auditor' | 'base'
16
17export type Animation = {
18  fps: number
19  frames: Frame[]
20  /** Píxeles sólo para el SVG: brillos, sombras, vidrio. */
21  shine?: Frame
22  /** Halo difuso detrás de Clawd, sólo en el SVG (color CSS). */
23  glow?: string
24}
25
26export type Skin = {
27  role: Role
28  label: string
29  accent: string
30  animate: (mood: Mood) => Animation
31}
32
33// ── Paleta ───────────────────────────────────────────────────────────────────
34export const C = {
35  body: 0xd97757,
36  bodyHi: 0xeb9a78,
37  bodySh: 0xb35f42,
38  lid: 0x9c4f37,
39  dead: 0x7d7d7d,
40  deadSh: 0x5f5f5f,
41  ink: 0x1f1e1d,
42  white: 0xf2efe8,
43  steel: 0xa9a9a9,
44  dark: 0x2e2e2e,
45  screen: 0x14202e,
46  green: 0x4eba65,
47  blue: 0x6b8afd,
48  yellow: 0xe5b93c,
49  red: 0xe5534b,
50  redHot: 0xff6b5e,
51  redDim: 0x7a2a26,
52  wood: 0x8b5a2b,
53  woodSh: 0x6b4420,
54}
55
56const span = (y: number, a: number, b: number, c: number): Px[] =>
57  Array.from({ length: b - a + 1 }, (_, i) => [a + i, y, c] as Px)
58
59// ── Clawd ────────────────────────────────────────────────────────────────────
60type Eyes = 'open' | 'right' | 'closed' | 'covered'
61type Arms = 'out' | 'typeL' | 'typeR' | 'hold'
62
63type Pose = { eyes: Eyes; arms: Arms; legs: 0 | 1; color: number }
64
65const LEGS: [number[], number[]] = [[4, 6, 11, 13], [5, 7, 10, 12]]
66
67function clawd({ eyes, arms, legs, color }: Pose): Px[] {
68  // Los ojos son huecos en la fila 3, como en el logo.
69  const holes = eyes === 'right' ? [6, 13] : [5, 12]
70  const row3: Px[] = []
71  for (let x = 3; x <= 14; x++) {
72    if (holes.includes(x)) {
73      if (eyes === 'closed') row3.push([x, 3, C.lid])
74      else if (eyes === 'covered') row3.push([x, 3, color])
75    } else row3.push([x, 3, color])
76  }
77  const arm = (side: 'L' | 'R', down: boolean): Px[] => {
78    const xs = side === 'L' ? [1, 2] : [15, 16]
79    return xs.map(x => [x, down ? 5 : 4, color] as Px)
80  }
81  const armL = arm('L', arms === 'typeL')
82  const armR = arms === 'hold' ? [[15, 4, color], [16, 4, color], [17, 4, color]] as Px[] : arm('R', arms === 'typeR')
83  return [
84    ...span(2, 3, 14, color),
85    ...row3,
86    ...span(4, 3, 14, color),
87    ...armL,
88    ...armR,
89    ...span(5, 3, 14, color),
90    ...LEGS[legs].map(x => [x, 6, color] as Px),
91  ]
92}
93
94/** Brillo arriba y sombra abajo, sólo en el SVG. */
95function bodyShine(color: number): Frame {
96  const hi = color === C.dead ? C.steel : C.bodyHi
97  const sh = color === C.dead ? C.deadSh : C.bodySh
98  return [...span(2, 4, 13, hi), ...span(5, 3, 14, sh)]
99}
100
101const bodyOf = (mood: Mood) => (mood === 'dead' ? C.dead : C.body)
102const tint = (mood: Mood, c: number) => (mood === 'dead' ? C.deadSh : c)
103
104/** El signo de espera, arriba a la derecha, en dos cuadros. */
105const BANG: Px[] = [[16, 0, C.yellow], [16, 1, C.yellow]]
106const waitFrames = (pose: Frame, blinkPose: Frame): Frame[] => [
107  [...pose, ...BANG],
108  [...pose, ...BANG],
109  [...pose],
110  [...blinkPose],
111]
112
113// ── Research: explorador que marcha con una bandera que flamea ─────────────
114// Tela: L = 1 claro, D = 2 sombra (la onda que corre), . = vacío. x 19–24, y 0–2.
115const FLAG_WAVE = [
116  ['11211.', '112111', '1121..'],
117  ['111211', '11121.', '11121.'],
118  ['1111..', '111121', '111112'],
119  ['12111.', '12111.', '121111'],
120]
121const FLAG_LIMP = ['11.', '11.', '1..', '1..'] // caída, sin viento: y 1–4, x 19–21
122
123function research(mood: Mood): Animation {
124  const color = bodyOf(mood)
125  const pole = tint(mood, C.steel)
126  const lite = tint(mood, C.yellow)
127  const dark = tint(mood, 0xb8891f)
128  const staff: Px[] = [[18, 0, tint(mood, C.yellow)], ...[1, 2, 3, 4, 5, 6].map(y => [18, y, pole] as Px)]
129  const cloth = (rows: string[], y0: number): Px[] =>
130    rows.flatMap((row, dy) => [...row].flatMap((ch, dx) =>
131      ch === '1' ? [[19 + dx, y0 + dy, lite] as Px] : ch === '2' ? [[19 + dx, y0 + dy, dark] as Px] : []))
132  if (mood === 'work') {
133    return {
134      fps: 8,
135      frames: Array.from({ length: 8 }, (_, f) => [
136        ...clawd({ eyes: f === 7 ? 'closed' : 'right', arms: 'hold', legs: (f >> 1) % 2 as 0 | 1, color }),
137        ...staff,
138        ...cloth(FLAG_WAVE[f % 4]!, 0),
139      ]),
140      shine: bodyShine(color),
141    }
142  }
143  const limp = cloth(FLAG_LIMP, 1)
144  const still = [...clawd({ eyes: mood === 'wait' ? 'open' : 'closed', arms: 'hold', legs: 0, color }), ...staff, ...limp]
145  if (mood === 'wait') {
146    const blink = [...clawd({ eyes: 'closed', arms: 'hold', legs: 0, color }), ...staff, ...limp]
147    return { fps: 3, frames: waitFrames(still, blink), shine: bodyShine(color) }
148  }
149  return { fps: 1, frames: [still], shine: bodyShine(color) }
150}
151
152// ── Developer: audífonos, laptop con código que corre, brazos tecleando ──────
153const CODE: Array<Array<[number, number]>> = [
154  // [largo, color] por segmento; cada fila cabe en x 20–24
155  [[2, C.blue], [2, C.white]],
156  [[1, 0x5a5a5a], [3, C.green]],
157  [[1, 0x5a5a5a], [2, C.yellow], [1, C.white]],
158  [[3, C.red], [1, C.white]],
159  [[2, C.blue], [1, C.white], [1, C.green]],
160  [[1, 0x5a5a5a], [1, 0x5a5a5a], [2, C.white]],
161  [[4, C.green]],
162  [[2, C.white], [2, C.blue]],
163]
164function codeRow(line: number, y: number): Px[] {
165  const out: Px[] = []
166  let x = 20
167  for (const [len, c] of CODE[line % CODE.length]!) {
168    for (let i = 0; i < len && x <= 24; i++) out.push([x++, y, c])
169  }
170  return out
171}
172
173function developer(mood: Mood): Animation {
174  const color = bodyOf(mood)
175  const phones = (led: boolean): Px[] => [
176    // Diadema fina sobre la cabeza y copas a los lados.
177    ...span(1, 3, 14, 0x3a3a3a),
178    [2, 1, C.dark], [2, 2, C.dark], [2, 3, C.dark], [15, 1, C.dark], [15, 2, C.dark], [15, 3, C.dark],
179    ...(led ? [[2, 3, tint(mood, C.green)] as Px] : []),
180  ]
181  const laptop = (scroll: number | null, cursor: boolean): Px[] => {
182    const frame = tint(mood, 0x4a4a4a)
183    const px: Px[] = [
184      ...span(1, 19, 25, frame), [19, 2, frame], [19, 3, frame], [19, 4, frame],
185      [25, 2, frame], [25, 3, frame], [25, 4, frame], ...span(5, 19, 25, frame),
186      ...span(6, 17, 25, tint(mood, 0x9a9a9a)),
187    ]
188    for (let y = 2; y <= 4; y++) px.push(...span(y, 20, 24, C.screen))
189    if (scroll !== null && mood !== 'dead') {
190      px.push(...codeRow(scroll, 2), ...codeRow(scroll + 1, 3))
191      const last = codeRow(scroll + 2, 4).slice(0, 2)
192      px.push(...last)
193      if (cursor) px.push([20 + last.length, 4, C.white])
194    }
195    return px
196  }
197  if (mood === 'work') {
198    return {
199      fps: 8,
200      frames: Array.from({ length: 8 }, (_, f) => [
201        ...clawd({ eyes: f === 6 ? 'closed' : 'right', arms: f % 2 ? 'typeL' : 'typeR', legs: 0, color }),
202        ...phones(f % 2 === 0),
203        ...laptop(f >> 1, f % 2 === 0),
204      ]),
205      shine: bodyShine(color),
206    }
207  }
208  const still = [...clawd({ eyes: mood === 'wait' ? 'open' : 'closed', arms: 'out', legs: 0, color }), ...phones(false), ...laptop(mood === 'wait' ? 0 : null, false)]
209  if (mood === 'wait') {
210    const blink = [...clawd({ eyes: 'closed', arms: 'out', legs: 0, color }), ...phones(false), ...laptop(0, false)]
211    return { fps: 3, frames: waitFrames(still, blink), shine: bodyShine(color) }
212  }
213  return { fps: 1, frames: [still], shine: bodyShine(color) }
214}
215
216// ── Auditor: visor con barrido rojo y portapapeles que se va marcando ───────
217function auditor(mood: Mood): Animation {
218  const color = bodyOf(mood)
219  const visor = (at: number | null): Px[] => {
220    // Gafas: no tocan los bordes, así Clawd sigue entero.
221    const band = span(3, 4, 13, C.dark)
222    if (at === null) return band
223    const lit: Px[] = [[at, 3, C.redHot], [at + 1, 3, C.red]]
224    const trail: Px[] = [[at - 1, 3, C.redDim], [at + 2, 3, C.redDim]].filter(([x]) => x! >= 4 && x! <= 13) as Px[]
225    return [...band, ...trail, ...lit]
226  }
227  const board = (checks: number, finding: boolean): Px[] => {
228    const wood = tint(mood, C.wood)
229    const px: Px[] = [
230      ...span(1, 18, 23, wood), ...span(6, 18, 23, tint(mood, C.woodSh)),
231      [18, 2, wood], [18, 3, wood], [18, 4, wood], [18, 5, wood],
232      [23, 2, wood], [23, 3, wood], [23, 4, wood], [23, 5, wood],
233      ...span(0, 20, 21, C.steel),
234    ]
235    for (let y = 2; y <= 5; y++) {
236      px.push([19, y, C.white], ...span(y, 20, 22, 0xbdbab3))
237    }
238    for (let i = 0; i < 4; i++) {
239      const y = 2 + i
240      if (i < checks) px.push([19, y, tint(mood, C.green)])
241      if (finding && i === 3) px.push([19, y, C.red])
242    }
243    return px
244  }
245  if (mood === 'work') {
246    const path = [4, 6, 8, 10, 12, 12, 10, 8, 6, 4]
247    return {
248      fps: 10,
249      frames: path.map((at, f) => [
250        ...clawd({ eyes: 'covered', arms: 'out', legs: 0, color }),
251        ...visor(at),
252        ...board(Math.min(3, f >> 1), f >= 8),
253      ]),
254      shine: bodyShine(color),
255    }
256  }
257  const still = [...clawd({ eyes: 'covered', arms: 'out', legs: 0, color }), ...visor(mood === 'wait' ? 8 : null), ...board(mood === 'sleep' ? 3 : 1, false)]
258  if (mood === 'wait') {
259    const dim = [...clawd({ eyes: 'covered', arms: 'out', legs: 0, color }), ...visor(null), ...board(1, false)]
260    return { fps: 3, frames: waitFrames(still, dim), shine: bodyShine(color) }
261  }
262  return { fps: 1, frames: [still], shine: bodyShine(color) }
263}
264
265// ── Base: Clawd solo, mientras no se sabe qué hace ──────────────────────────
266function base(mood: Mood): Animation {
267  const color = bodyOf(mood)
268  if (mood === 'work') {
269    return {
270      fps: 4,
271      frames: Array.from({ length: 8 }, (_, f) =>
272        clawd({ eyes: f === 7 ? 'closed' : 'open', arms: 'out', legs: (f % 2) as 0 | 1, color })),
273      shine: bodyShine(color),
274    }
275  }
276  const still = clawd({ eyes: mood === 'wait' ? 'open' : 'closed', arms: 'out', legs: 0, color })
277  if (mood === 'wait') {
278    return { fps: 3, frames: waitFrames(still, clawd({ eyes: 'closed', arms: 'out', legs: 0, color })), shine: bodyShine(color) }
279  }
280  return { fps: 1, frames: [still], shine: bodyShine(color) }
281}
282
283export const SKINS: Record<Role, Skin> = {
284  research: { role: 'research', label: 'investigando', accent: '#E5B93C', animate: research },
285  developer: { role: 'developer', label: 'programando', accent: '#4EBA65', animate: developer },
286  auditor: { role: 'auditor', label: 'auditando', accent: '#E5534B', animate: auditor },
287  base: { role: 'base', label: 'arrancando', accent: '#D97757', animate: base },
288}
289
290// ── Nivel del modelo: chispas para Opus, halo y chispas doradas para Fable ──
291const SPARK_HIGH = 0xc4b5fd
292const SPARK_TOP = 0xf2c14e
293const HALO_TOP = [0x7a5a12, 0xb8891f] // tenue y brillante, late cuadro a cuadro
294
295/** Chispas en las esquinas libres de Clawd, alternando entre dos lugares. */
296const sparks = (c: number, on: 0 | 1): Px[] =>
297  on === 0 ? [[0, 0, c], [1, 7, c]] : [[0, 6, c], [17, 0, c]]
298
299/** Contorno de píxeles alrededor de Clawd, para el terminal (en el SVG es un brillo). */
300const halo = (c: number): Px[] => [
301  ...span(1, 4, 13, c), [2, 1, c], [15, 1, c],
302  [2, 2, c], [2, 3, c], [0, 4, c], [17, 4, c], [2, 5, c], [15, 5, c], [15, 2, c], [15, 3, c],
303]
304
305/**
306 * Suma el nivel del modelo a una animación. `low` y `mid` no suman nada; así
307 * las chispas y el halo saltan a la vista. Con Clawd caído no se adorna.
308 */
309export function withTier(anim: Animation, tier: Tier, mood: Mood, surface: 'term' | 'svg'): Animation {
310  if (tier === 'low' || tier === 'mid' || mood === 'dead') return anim
311  // Una pose quieta se duplica para que las chispas titilen.
312  const base = anim.frames.length === 1 ? [anim.frames[0]!, anim.frames[0]!] : anim.frames
313  const fps = anim.frames.length === 1 ? 2 : anim.fps
314  const spark = tier === 'top' ? SPARK_TOP : SPARK_HIGH
315  const frames = base.map((f, i) => {
316    const on = (Math.floor((i * 4) / base.length) % 2) as 0 | 1
317    // El halo va debajo: Clawd y sus accesorios lo tapan donde se cruzan.
318    const under = tier === 'top' && surface === 'term' ? halo(HALO_TOP[on]!) : []
319    return [...under, ...f, ...sparks(spark, on)]
320  })
321  return { ...anim, fps, frames, glow: tier === 'top' && surface === 'svg' ? '#F2C14E' : anim.glow }
322}
323
324// ── Pintores ─────────────────────────────────────────────────────────────────
325const hex = (n: number) => `#${n.toString(16).padStart(6, '0')}`
326const key = ([x, y, c]: Px) => `${x},${y},${c}`
327
328function flatten(frame: Frame): Map<string, number> {
329  // El último píxel escrito en una posición gana.
330  const m = new Map<string, number>()
331  for (const [x, y, c] of frame) if (x >= 0 && x < W && y >= 0 && y < H) m.set(`${x},${y}`, c)
332  return m
333}
334const toPx = (m: Map<string, number>): Px[] =>
335  [...m].map(([k, c]) => { const [x, y] = k.split(',').map(Number); return [x!, y!, c] as Px })
336
337const rects = (px: Px[], extra = '') =>
338  px.map(([x, y, c]) => `<rect x="${x}" y="${y}" width="1.02" height="1.02" fill="${hex(c)}"${extra}/>`).join('')
339
340/** SVG animado (SMIL, cuadros discretos); `px` de ancho. */
341export function svgOf(anim: Animation, mood: Mood, px: number): string {
342  const frames = anim.frames.map(f => toPx(flatten(f)))
343  const shared = frames.slice(1).reduce(
344    (acc, f) => { const s = new Set(f.map(key)); return acc.filter(p => s.has(key(p))) },
345    frames[0]!,
346  )
347  const sharedKeys = new Set(shared.map(key))
348  const n = frames.length
349  const dur = `${(n / anim.fps).toFixed(3)}s`
350  const keyTimes = Array.from({ length: n }, (_, i) => (i / n).toFixed(4)).join(';')
351  const layers = n === 1 ? '' : frames.map((f, i) => {
352    const values = Array.from({ length: n }, (_, j) => (j === i ? 'visible' : 'hidden')).join(';')
353    return `<g visibility="${i === 0 ? 'visible' : 'hidden'}">${rects(f.filter(p => !sharedKeys.has(key(p))))}<animate attributeName="visibility" values="${values}" keyTimes="${keyTimes}" dur="${dur}" calcMode="discrete" repeatCount="indefinite"/></g>`
354  }).join('')
355  // El brillo sólo cae sobre píxeles del cuerpo que siguen siendo cuerpo.
356  const bodyKeys = new Set(shared.filter(([, , c]) => c === C.body || c === C.dead).map(([x, y]) => `${x},${y}`))
357  const shine = (anim.shine ?? []).filter(([x, y]) => bodyKeys.has(`${x},${y}`))
358  const zz = mood === 'sleep'
359    ? `<text x="15.5" y="3.4" font-size="3.6" font-family="ui-monospace,Menlo,monospace" font-weight="700" fill="#8A8A8A">z<animate attributeName="opacity" values=".15;1;.15" dur="2.4s" repeatCount="indefinite"/><animateTransform attributeName="transform" type="translate" values="0 0;0 -1.2;0 0" dur="2.4s" repeatCount="indefinite"/></text>`
360    : ''
361  const ground = `<ellipse cx="9" cy="14.6" rx="7.5" ry=".7" fill="#000" opacity=".22"/>`
362  const glow = anim.glow
363    ? `<defs><radialGradient id="g"><stop offset="0" stop-color="${anim.glow}" stop-opacity=".75"/><stop offset="1" stop-color="${anim.glow}" stop-opacity="0"/></radialGradient></defs><ellipse cx="9" cy="8" rx="11" ry="8.5" fill="url(#g)"><animate attributeName="opacity" values=".45;1;.45" dur="1.8s" repeatCount="indefinite"/></ellipse>`
364    : ''
365  const h = Math.round((px * (H * 2 + 1)) / W)
366  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -1 ${W} ${H * 2 + 1}" width="${px}" height="${h}" shape-rendering="crispEdges">${glow}${ground}<g transform="scale(1 2)">${anim.glow ? `<rect x="3" y="2" width="12" height="4" fill="${hex(C.ink)}"/>` : ''}${rects(shared)}${rects(shine, ' opacity=".55"')}${layers}</g>${zz}</svg>`
367}
368
369/** Celdas del Raster del terminal para un cuadro: cuartos de bloque, 2×2 píxeles. */
370export const COLS = W / 2
371export const ROWS = H / 2
372const QUAD = ' ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█'
373const DEFAULT = 0x01000000
374
375export function cellsOf(frame: Frame): Uint32Array {
376  const m = flatten(frame)
377  const at = (x: number, y: number) => m.get(`${x},${y}`) ?? -1
378  const words = new Uint32Array(COLS * ROWS * 3)
379  for (let r = 0; r < ROWS; r++) {
380    for (let cx = 0; cx < COLS; cx++) {
381      const q = [at(2 * cx, 2 * r), at(2 * cx + 1, 2 * r), at(2 * cx, 2 * r + 1), at(2 * cx + 1, 2 * r + 1)]
382      const count = new Map<number, number>()
383      for (const c of q) if (c >= 0) count.set(c, (count.get(c) ?? 0) + 1)
384      const ranked = [...count.entries()].sort((a, b) => b[1] - a[1]).map(([c]) => c)
385      const i = (r * COLS + cx) * 3
386      if (ranked.length === 0) { words.set([0x20, DEFAULT, DEFAULT], i); continue }
387      const fg = ranked[0]!
388      const bg = q.some(c => c < 0) ? DEFAULT : ranked[1] ?? DEFAULT
389      let mask = 0
390      q.forEach((c, b) => { if (c === fg) mask |= 1 << b })
391      words.set([QUAD.charCodeAt(mask), fg, bg], i)
392    }
393  }
394  return words
395}
396
hooks/models.ts 79 lines
1// Ventana de contexto y precio de lista por modelo (API de Anthropic, USD por
2// millón de tokens). Fuente: tabla de modelos de la referencia de la API al
3// 2026-10-06. Bedrock y Vertex cobran aparte: el costo del panel es estimado.
4//
5// Se busca por prefijo del id, de lo más específico a lo más general.
6
7export type ModelInfo = {
8  /** Tokens de entrada que caben. */
9  window: number
10  /** [entrada, salida, lectura de caché, escritura de caché] */
11  price: [number, number, number, number]
12}
13
14const M = 1_000_000
15const K200 = 200_000
16// Escritura de caché a 1,25× la entrada (TTL de 5 minutos).
17const p = (inp: number, out: number, read: number): ModelInfo['price'] => [inp, out, read, inp * 1.25]
18
19const TABLE: Array<[string, ModelInfo]> = [
20  ['fable-5-1', { window: M, price: p(10, 50, 0.25) }],
21  ['mythos-5-1', { window: M, price: p(10, 50, 0.25) }],
22  ['fable-5', { window: M, price: p(10, 50, 1) }],
23  ['mythos-5', { window: M, price: p(10, 50, 1) }],
24  ['opus-5-5', { window: M, price: p(4, 20, 0.2) }],
25  ['opus-5', { window: M, price: p(5, 25, 0.5) }],
26  ['opus-4-8', { window: M, price: p(5, 25, 0.5) }],
27  ['opus-4-7', { window: M, price: p(5, 25, 0.5) }],
28  ['opus-4-6', { window: M, price: p(5, 25, 0.5) }],
29  ['opus-4-5', { window: K200, price: p(5, 25, 0.5) }],
30  ['opus', { window: K200, price: p(15, 75, 1.5) }],
31  ['sonnet-5-5', { window: M, price: p(2, 10, 0.2) }],
32  ['sonnet-5', { window: M, price: p(2, 10, 0.2) }],
33  ['sonnet-4-6', { window: M, price: p(3, 15, 0.3) }],
34  ['sonnet', { window: K200, price: p(3, 15, 0.3) }],
35  ['haiku-5-5', { window: M, price: p(0.1, 0.5, 0.01) }],
36  ['haiku-4-5', { window: K200, price: p(1, 5, 0.1) }],
37  ['haiku', { window: K200, price: p(1, 5, 0.1) }],
38]
39
40const UNKNOWN: ModelInfo = { window: K200, price: p(3, 15, 0.3) }
41
42export function modelInfo(model: string): ModelInfo {
43  const id = model.toLowerCase()
44  const hit = TABLE.find(([key]) => id.includes(key))?.[1] ?? UNKNOWN
45  // Un sufijo [1m] fuerza la ventana de un millón.
46  return /\[1m\]/.test(id) ? { ...hit, window: M } : hit
47}
48
49export type Usage = { input: number; output: number; cacheRead: number; cacheWrite: number }
50
51/** Costo en USD de un paso. Haiku 5.5 cobra 5× por encima de 100k tokens de prompt. */
52export function costOf(model: string, u: Usage): number {
53  let [inp, out, read, write] = modelInfo(model).price
54  const prompt = u.input + u.cacheRead + u.cacheWrite
55  if (/haiku-5-5/i.test(model) && prompt > 100_000) {
56    inp *= 5; out *= 5; read *= 5; write *= 5
57  }
58  return (u.input * inp + u.output * out + u.cacheRead * read + u.cacheWrite * write) / M
59}
60
61// ── Nivel de costo: cuánto destaca el modelo en el panel ────────────────────
62export type Tier = 'low' | 'mid' | 'high' | 'top'
63
64export function tierOf(model: string): Tier {
65  const id = model.toLowerCase()
66  if (/fable|mythos/.test(id)) return 'top'
67  if (/opus/.test(id)) return 'high'
68  if (/haiku/.test(id)) return 'low'
69  return 'mid'
70}
71
72/** Cómo se escribe el modelo: Haiku apagado, Sonnet normal, Opus violeta, Fable dorado. */
73export const TIER_STYLE: Record<Tier, { mark: string; color?: string; bold: boolean; dim: boolean }> = {
74  low: { mark: '', dim: true, bold: false },
75  mid: { mark: '● ', color: '#6B8AFD', dim: false, bold: false },
76  high: { mark: '◆ ', color: '#A78BFA', dim: false, bold: true },
77  top: { mark: '✦ ', color: '#F2C14E', dim: false, bold: true },
78}
79
types/index.d.ts 24 lines
1export type AgentRow = {
2  id: string
3  type: string
4  description: string
5  status: string
6  model: string
7  input: number
8  output: number
9  cacheRead: number
10  cacheWrite: number
11  context: number
12  usd: number
13  /** Qué fueron sus últimas llamadas ('read' | 'write' | 'other'), de la más vieja a la más nueva. */
14  tools: string[]
15  /** Cuándo dejó de trabajar (ms); sin valor mientras trabaja o espera. */
16  endedAt?: number
17}
18
19declare module 'claude-code' {
20  interface PluginState {
21    'agent-monitor': { rows: Record<string, AgentRow>; frame: number; opened: boolean; now: number }
22  }
23}
24