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

Panel en vivo para Claude Code que muestra tus subagentes. Cada uno es un Clawd, la mascota de Claude Code:
▐▛███▜▌
▝▜█████▛▘
▘▘ ▝▝
Cada tarjeta muestra:
! 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.El skin de Clawd cambia en vivo según lo que hace el agente:
| Skin | Cuándo | Animación |
|---|---|---|
| research · investigando | Sus ú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 · programando | Sus ú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 · auditando | Su tipo o su tarea habla de revisar, auditar, seguridad o QA | Gafas con un barrido rojo y un portapapeles que se va marcando |
| base | Antes de su primera herramienta | Camina y parpadea |
Cuanto más caro el modelo, más destaca, sin tocar el borde de la tarjeta (que es del estado):
| Modelo | Etiqueta | Clawd |
|---|---|---|
| Haiku | gris tenue | normal |
| Sonnet | ● Sonnet con punto azul | normal |
| Opus | ◆ Opus violeta en negrita | dos chispas que titilan |
| Fable | ✦ Fable dorado en negrita | halo 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).
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.
/agent-monitor. Con una ventana de 144 columnas o más, el panel se abre solo con el primer subagente.claude plugin marketplace update jorge-mods
claude plugin update agent-monitor@jorge-mods
Después escribe /reload-plugins en la sesión abierta.
MIT. Ver LICENSE.
hooks/register.tsx 336 lines1import { 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}
336hooks/skins.ts 396 lines1// 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}
396hooks/models.ts 79 lines1// 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}
79types/index.d.ts 24 lines1export 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