SLOPSHOPPER

agent-bots

Panel lateral con cada subagente como un robot animado (según su modelo y estado) y, debajo, la lista de tareas de la sesión en vivo

newpaneguardcommandtoastprompt
★ 1v0.6.0MITupdated 2026-10-16gabriel9004/claude-code-mods/agent-bots
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-bots
│ ┃ Agentes y tareas ✕ › fix the failing auth test and add an audit log call │ ┃ 5 h ███░░░░░░░ 31% │ ┃ ⏺ Read(src/auth.ts) │ ┃ 0 trabajando · 0 esperando · 0 listos ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Ningún agente ni comando todavía. Aparecerán ⎿ Added 2 lines, removed 1 line │ ┃ aquí en cuanto se lance uno. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ● Opus ● Sonnet ● Haiku ● Comando │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Tareas │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ 😴 Sin tareas todavía │ ┃ Aparecerán aquí cuando se arme un plan. › /bots │ ⎿ agent-bots: Panel de agentes y tareas abierto. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agentes y tareas
5 h ███░░░░░░░ 31% 0 trabajando · 0 esperando · 0 listos Ningún agente ni comando todavía. Aparecerán aquí en cuanto se lance uno. ● Opus ● Sonnet ● Haiku ● Comando Tareas 😴 Sin tareas todavía Aparecerán aquí cuando se arme un plan.
README

claude-code-mods

Dos mods para Claude Code que muestran en pantalla lo que normalmente no se ve: qué hacen tus subagentes, cuánto te queda de cuota y cómo va la caché de prompts.

El panel de agent-bots en la app de escritorio, con agentes y tareas de ejemplo.

Aviso: la API de mods de Claude Code es de acceso anticipado y puede cambiar entre versiones. Probados con Claude Code 2.1.292 y 2.1.299.

ModQué haceDónde se ve
agent-botsPanel lateral «Agentes y tareas»: cada subagente (y cada comando en segundo plano) como un robot animado, la lista de tareas de la sesión en vivo y tres anillos: cuota de 5 h, cuota semanal y caché de promptsApp de escritorio (en terminal, versión de texto)
medidorBanda sobre el prompt: modelo y esfuerzo, contexto, límites de 5 h y semana con su reinicio, costo, duración y estado de la caché con cuenta regresivaTerminal

Los dos se complementan: medidor solo dibuja en la terminal y agent-bots hace lo mismo en la app de escritorio. Puedes instalar solo uno.

Instalación

/plugin marketplace add gabriel9004/claude-code-mods
/plugin install agent-bots@claude-code-mods
/plugin install medidor@claude-code-mods
/reload-plugins

Luego /tareas o /bots abre el panel y /medidor [completo|compacto|oculto] cambia la banda.

Qué lee cada mod

Un mod se ejecuta con acceso a la sesión, así que conviene saber qué toca. Ninguno envía datos a ningún sitio ni guarda nada en disco.

  • agent-bots: los subagentes y herramientas que usa la sesión, la lista de tareas, los porcentajes de cuota (session.usage), el uso de tokens de cada petición para la caché y tres variables de entorno de solo lectura (FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, ENABLE_PROMPT_CACHING_1H). Registra una herramienta propia, tareas, y solo añade una línea al prompt de sistema si la sesión no trae ninguna herramienta de tareas.
  • medidor: modelo, esfuerzo, uso de contexto y de cuota, costo, tokens de cada petición y las mismas tres variables de entorno.

Desarrollo

Cada mod trae sus pruebas:

cd agent-bots && claude plugin validate . && claude plugin test .
cd ../medidor && claude plugin validate .

Licencia y créditos

MIT. La lógica de la caché toma ideas de otro proyecto MIT; ver NOTICE.md.


English

Two Claude Code mods: agent-bots (a desktop side panel with animated robots for subagents, a live task list and quota/cache rings) and medidor (a terminal band with model, context, 5 h / weekly limits, cost and prompt-cache countdown). The mods API is early access and may change.

/plugin marketplace add gabriel9004/claude-code-mods
/plugin install agent-bots@claude-code-mods
/plugin install medidor@claude-code-mods

MIT licensed. The UI text is in Spanish.

Source 3 files
hooks/register.tsx 946 lines
1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Bot, BotKind, BotMood, Limit, Muestra, Task, TaskStatus } from '../types'
5import { aciertos, decidirTtl, observarTtl, restanteMs, tokens } from './cache'
6
7const PANE = 'agent-bots'
8const TITLE = 'Agentes y tareas'
9const KEEP = 12
10
11const bots = atom({ plugin: 'agent-bots', key: 'bots' } as const, [])
12const tasks = atom({ plugin: 'agent-bots', key: 'tasks' } as const, [])
13const limits = atom({ plugin: 'agent-bots', key: 'limits' } as const, [])
14const replayed = atom({ plugin: 'agent-bots', key: 'replayed' } as const, false)
15const samples = atom({ plugin: 'agent-bots', key: 'muestras' } as const, [])
16const cacheTtl = atom({ plugin: 'agent-bots', key: 'ttl' } as const, '5m')
17
18// Las dos ventanas de cuota que se dibujan, en este orden.
19const QUOTA: { kind: string; label: string; ramp: [string, string] }[] = [
20  { kind: 'five_hour', label: '5 h', ramp: ['#AFA9EC', '#7F77DD'] },
21  { kind: 'seven_day', label: 'Semana', ramp: ['#5DCAA5', '#1D9E75'] },
22]
23const WARN: [string, string] = ['#FAC775', '#EF9F27']
24const HOT: [string, string] = ['#F09595', '#E24B4A']
25const now = atom({ plugin: 'agent-bots', key: 'now' } as const, 0)
26
27const TASK_LABEL: Record<TaskStatus, [string, string]> = {
28  in_progress: ['En curso', '#378ADD'],
29  pending: ['Pendiente', '#888780'],
30  completed: ['Hecha', '#639922'],
31}
32const TASK_GLYPH: Record<TaskStatus, string> = { in_progress: '🔄', pending: '⏳', completed: '✅' }
33
34// Herramientas de la app que mueven la lista de tareas (se reconocen por el sufijo del nombre).
35const PLAN_TOOL = '__update_plan'
36const WRAP_TOOL = '__wrap_up'
37
38// Herramienta propia del mod, para las sesiones que no traen ninguna de tareas.
39const OWN_TOOL = 'mcp__agent-bots__tareas'
40const OWN_SPEC = {
41  name: 'tareas',
42  description:
43    'Muestra al usuario, en un panel lateral, la lista de tareas de la sesión. Envía el plan completo con «items»; ' +
44    'después marca el avance con «advance», «done» o «start». Un paso a la vez en curso.',
45  isDeferred: false,
46  inputSchema: {
47    type: 'object',
48    properties: {
49      items: {
50        type: 'array',
51        description: 'La lista completa de pasos, en orden; sustituye a la anterior.',
52        items: {
53          type: 'object',
54          properties: {
55            content: { type: 'string', description: 'El paso, en pocas palabras.' },
56            status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] },
57          },
58          required: ['content', 'status'],
59        },
60      },
61      advance: { type: 'boolean', description: 'Marca como hecho el paso en curso y pone en curso el siguiente pendiente.' },
62      done: { type: 'array', items: { type: 'number' }, description: 'Números de paso (desde 1) que pasan a hechos.' },
63      start: { type: 'number', description: 'Número de paso (desde 1) que pasa a en curso.' },
64    },
65  },
66}
67const OWN_PROMPT =
68  `Dispones de la herramienta \`${OWN_TOOL}\`, que muestra al usuario la lista de tareas en un panel lateral. ` +
69  'En un trabajo de varios pasos, envía el plan completo al empezar (items, cada uno con status pending, in_progress o completed), ' +
70  'llama con advance: true al terminar cada paso y reenvía la lista completa si el plan cambia. No la uses para preguntas simples.'
71// Si la sesión ya trae su propia herramienta de tareas, la del mod no se ofrece en el prompt.
72const HOST_LISTS = (name: string) =>
73  name.endsWith(PLAN_TOOL) || name === 'TodoWrite' || name === 'TaskCreate' || name === 'TaskUpdate'
74
75// Relleno, trazo y tinta de cada familia de modelo (rampas del mockup).
76type Ramp = [string, string, string]
77type Family = 'opus' | 'sonnet' | 'haiku' | 'other'
78const RAMP: Record<Family, Ramp> = {
79  opus: ['#EEEDFE', '#7F77DD', '#3C3489'],
80  sonnet: ['#E6F1FB', '#378ADD', '#0C447C'],
81  haiku: ['#E1F5EE', '#1D9E75', '#085041'],
82  other: ['#F1EFE8', '#888780', '#444441'],
83}
84const FAIL: Ramp = ['#FCEBEB', '#E24B4A', '#791F1F']
85const SHELL: Ramp = ['#FAEEDA', '#BA7517', '#633806']
86
87const LABEL: Record<BotMood, [string, string]> = {
88  run: ['Trabajando', '#378ADD'],
89  sleep: ['Esperando', '#BA7517'],
90  done: ['Listo', '#639922'],
91  fail: ['Falló', '#E24B4A'],
92}
93const GLYPH: Record<BotMood, string> = { run: '🤖', sleep: '😴', done: '✅', fail: '💥' }
94
95function family(model: string): Family {
96  const m = model.toLowerCase()
97  for (const f of ['opus', 'sonnet', 'haiku'] as const) if (m.includes(f)) return f
98  return 'other'
99}
100
101function modelName(model: string): string {
102  const f = family(model)
103  if (f === 'other') return model || '?'
104  const v = /(\d+)[-.](\d+)/.exec(model)
105  return f.charAt(0).toUpperCase() + f.slice(1) + (v ? ` ${v[1]}.${v[2]}` : '')
106}
107
108function mood(status: string): BotMood {
109  if (status === 'running' || status === 'pending') return 'run'
110  if (status === 'waiting' || status === 'idle') return 'sleep'
111  if (status === 'completed') return 'done'
112  return 'fail'
113}
114
115const isEnded = (status: string) => ['completed', 'failed', 'killed'].includes(status)
116
117function elapsed(ms: number): string {
118  const s = Math.max(0, Math.floor(ms / 1000))
119  if (s < 60) return `${s}s`
120  const m = Math.floor(s / 60)
121  return m < 60 ? `${m}m ${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
122}
123
124function bar(done: number, total: number, width = 16): string {
125  const full = total ? Math.round((done / total) * width) : 0
126  return '█'.repeat(full) + '░'.repeat(width - full)
127}
128
129function base(path: unknown): string {
130  return typeof path === 'string' ? path.split(/[\\/]/).pop() || path : ''
131}
132
133function clip(text: unknown, n = 38): string {
134  const t = typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : ''
135  return t.length > n ? t.slice(0, n - 1) + '…' : t
136}
137
138// Lo que dice el bocadillo según la herramienta que el agente acaba de usar.
139function action(tool: string, input: Record<string, unknown>): string {
140  switch (tool) {
141    case 'Read': return `📖 Leyendo ${base(input.file_path)}`
142    case 'Write': return `✏️ Creando ${base(input.file_path)}`
143    case 'Edit': return `✏️ Editando ${base(input.file_path)}`
144    case 'Grep': return `🔍 Grep "${clip(input.pattern, 28)}"`
145    case 'Glob': return `🗂️ Buscando ${clip(input.pattern, 28)}`
146    case 'Bash':
147    case 'PowerShell': return `⚙️ ${clip(input.command)}`
148    case 'WebFetch':
149    case 'WebSearch': return `🌐 ${clip(input.url ?? input.query)}`
150    case 'Agent': return `🤖 Lanzando ${clip(input.description, 30)}`
151    default: return `🔧 ${clip(tool.replace(/^mcp__[^_]+__/, ''), 34)}`
152  }
153}
154
155function robot(m: BotMood, model: string, kind: BotKind = 'agent'): string {
156  const [fill, stroke, ink] = m === 'fail' ? FAIL : kind === 'shell' ? SHELL : RAMP[family(model)]
157  const loop = 'repeatCount="indefinite"'
158  const bodyAnim =
159    m === 'run' ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 -3;0 0" dur="1.2s" ${loop}/>`
160    : m === 'done' ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 -8;0 0;0 0" keyTimes="0;0.2;0.4;1" dur="2.5s" ${loop}/>`
161    : m === 'fail' ? `<animateTransform attributeName="transform" type="translate" values="0 0;-2 0;2 0;0 0" dur="0.4s" repeatCount="3"/>`
162    : ''
163  const blink = m === 'run' ? `<animate attributeName="opacity" values="1;0.2;1" dur="0.8s" ${loop}/>` : ''
164  const look = m === 'run'
165    ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 0;2.5 0;2.5 0;0 0" keyTimes="0;0.4;0.5;0.9;1" dur="3s" ${loop}/>`
166    : ''
167  const eyes =
168    m === 'sleep' ? `<rect x="20" y="25" width="8" height="2" rx="1" fill="${ink}"/><rect x="36" y="25" width="8" height="2" rx="1" fill="${ink}"/>`
169    : m === 'fail' ? `<path d="M20 22l7 6M27 22l-7 6M37 22l7 6M44 22l-7 6" stroke="${ink}" stroke-width="2"/>`
170    : `<g>${look}<circle cx="24" cy="25" r="4" fill="${ink}"/><circle cx="40" cy="25" r="4" fill="${ink}"/><circle cx="25.5" cy="23.5" r="1.2" fill="#fff"/><circle cx="41.5" cy="23.5" r="1.2" fill="#fff"/></g>`
171  const mouth =
172    m === 'done' ? `<path d="M26 33q6 5 12 0" stroke="${ink}" stroke-width="2" fill="none"/>`
173    : m === 'fail' ? `<path d="M26 36q6-5 12 0" stroke="${ink}" stroke-width="2" fill="none"/>`
174    : `<rect x="27" y="33" width="10" height="2" rx="1" fill="${ink}"/>`
175  const z = (x: number, y: number, size: number, begin: string) =>
176    `<text x="${x}" y="${y}" font-size="${size}" font-family="sans-serif" fill="${stroke}" opacity="0">z` +
177    `<animate attributeName="opacity" values="0;1;0" dur="2.4s" begin="${begin}" ${loop}/>` +
178    `<animateTransform attributeName="transform" type="translate" values="0 0;8 -14" dur="2.4s" begin="${begin}" ${loop}/></text>`
179  const extra =
180    m === 'sleep' ? z(46, 14, 11, '0s') + z(50, 10, 9, '1.2s')
181    : m === 'done' ? `<circle cx="52" cy="52" r="7" fill="#639922"/><path d="M48.5 52l2.5 2.5 4-4.5" stroke="#fff" stroke-width="1.8" fill="none"/>`
182    : ''
183  // El robot de un comando lleva un engranaje en lugar de antena, que gira mientras corre.
184  const spin = m === 'run'
185    ? `<animateTransform attributeName="transform" type="rotate" values="0 32 7;360 32 7" dur="2s" ${loop}/>`
186    : ''
187  const top = kind === 'shell'
188    ? `<g>${spin}<circle cx="32" cy="7" r="5" fill="none" stroke="${stroke}" stroke-width="3" stroke-dasharray="2.6 1.6"/>` +
189      `<circle cx="32" cy="7" r="2.2" fill="${stroke}"/></g>`
190    : `<line x1="32" y1="6" x2="32" y2="13" stroke="${stroke}" stroke-width="2"/>` +
191      `<circle cx="32" cy="5" r="3" fill="${stroke}">${blink}</circle>`
192  return (
193    `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">` +
194    `<g opacity="${m === 'done' ? 0.8 : 1}"><g>${bodyAnim}` +
195    top +
196    `<rect x="12" y="13" width="40" height="28" rx="9" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>` +
197    eyes + mouth +
198    `<rect x="20" y="43" width="24" height="14" rx="5" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>` +
199    `<circle cx="32" cy="50" r="2.5" fill="${stroke}">${blink}</circle>` +
200    `</g></g>${extra}</svg>`
201  )
202}
203
204// Recorta la lista: primero se van los terminados más antiguos.
205function trim(list: Bot[]): Bot[] {
206  let out = list
207  while (out.length > KEEP) {
208    const i = out.findIndex(b => isEnded(b.status))
209    out = out.filter((_, j) => j !== (i === -1 ? 0 : i))
210  }
211  return out
212}
213
214// Anillo de cuota, estático a propósito: el panel se redibuja cada segundo mientras hay tareas en curso
215// y cualquier animación SMIL en el SVG se reinicia con cada redibujo (ese era el latido). El color avisa:
216// ámbar desde el 70 % y rojo desde el 90 %, salvo en los anillos donde llenarse es bueno (caché).
217function gauge(pct: number, ramp: [string, string], id: string, warn = true): string {
218  const p = Math.max(0, Math.min(100, pct))
219  const [light, deep] = warn && p >= 90 ? HOT : warn && p >= 70 ? WARN : ramp
220  const c = 2 * Math.PI * 30
221  const len = ((p / 100) * c).toFixed(1)
222  return (
223    `<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 80 80">` +
224    `<defs><linearGradient id="q-${id}" x1="0" y1="0" x2="1" y2="1">` +
225    `<stop offset="0" stop-color="${light}"/><stop offset="1" stop-color="${deep}"/></linearGradient></defs>` +
226    `<circle cx="40" cy="40" r="30" fill="none" stroke="#888780" stroke-opacity="0.22" stroke-width="7"/>` +
227    `<g transform="rotate(-90 40 40)"><circle cx="40" cy="40" r="30" fill="none" stroke="url(#q-${id})" stroke-width="7" ` +
228    `stroke-linecap="round" stroke-dasharray="${len} ${c.toFixed(1)}"/></g>` +
229    `<text x="40" y="46" text-anchor="middle" font-family="sans-serif" font-size="17" font-weight="600" fill="${deep}">${Math.round(p)}%</text>` +
230    `</svg>`
231  )
232}
233
234function bar10(pct: number): string {
235  const full = Math.round(Math.max(0, Math.min(100, pct)) / 10)
236  return '█'.repeat(full) + '░'.repeat(10 - full)
237}
238
239// Cuánto falta para que la ventana se reinicie: «45 min», «2h 14m» o «3d 4h».
240function resetIn(iso: string | undefined, t: number): string {
241  const at = iso ? Date.parse(iso) : NaN
242  if (!Number.isFinite(at)) return ''
243  const m = Math.max(0, Math.round((at - t) / 60000))
244  if (m < 60) return `${m} min`
245  const h = Math.floor(m / 60)
246  if (h < 24) return `${h}h ${String(m % 60).padStart(2, '0')}m`
247  return `${Math.floor(h / 24)}d ${h % 24}h`
248}
249
250// Guarda las ventanas de 5 h y semana; avisa una vez al cruzar el 90 %.
251async function readLimits($: EngineInterface, fresh: readonly { kind: string; percentUsed: number; resetsAt?: string }[]) {
252  const before = await read($, limits)
253  const next: Limit[] = []
254  for (const { kind, label } of QUOTA) {
255    const got = fresh.find(l => l.kind === kind)
256    if (!got) continue
257    const old = before.find(l => l.kind === kind)
258    next.push({ kind, percentUsed: got.percentUsed, prevPercent: old?.percentUsed ?? 0, resetsAt: got.resetsAt })
259    if ((old?.percentUsed ?? 0) < 90 && got.percentUsed >= 90) {
260      try {
261        void $.ui.toast(`Cuota de ${label} al ${Math.round(got.percentUsed)} %`)
262      } catch {}
263    }
264  }
265  if (next.length) await update($, limits, () => next)
266  startQuotaClock($)
267}
268
269let quotaClock: Timer | undefined
270
271// La cuenta regresiva del reinicio se mueve aunque no haya agentes: un pulso por minuto.
272function startQuotaClock($: EngineInterface) {
273  if (quotaClock) return
274  quotaClock = $.clock.every(60000, () => {
275    void $.clock.now().then(t => update($, now, () => t)).catch(() => {})
276  })
277}
278
279function asStatus(value: unknown): TaskStatus {
280  return value === 'in_progress' || value === 'completed' ? value : 'pending'
281}
282
283// El indicador de cada tarea: anillo que gira, visto verde o círculo punteado.
284function taskMark(s: TaskStatus): string {
285  const loop = 'repeatCount="indefinite"'
286  const body =
287    s === 'completed'
288      ? `<circle cx="12" cy="12" r="10" fill="#639922"/><path d="M7.5 12.5l3 3 6-7" stroke="#fff" stroke-width="2.2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`
289      : s === 'in_progress'
290        ? `<circle cx="12" cy="12" r="9" fill="none" stroke="#B5D4F4" stroke-width="3"/>` +
291          `<g><animateTransform attributeName="transform" type="rotate" values="0 12 12;360 12 12" dur="1s" ${loop}/>` +
292          `<path d="M12 3a9 9 0 0 1 9 9" fill="none" stroke="#378ADD" stroke-width="3" stroke-linecap="round"/></g>`
293        : `<circle cx="12" cy="12" r="9" fill="none" stroke="#B4B2A9" stroke-width="2" stroke-dasharray="3 3"/>`
294  return `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">${body}</svg>`
295}
296
297// Recibe una lista nueva y conserva los tiempos de las tareas que ya estaban.
298function fromItems(old: Task[], items: { content: string; status: TaskStatus }[], t: number): Task[] {
299  const used = new Set<number>()
300  return items.map((it, i) => {
301    const j = old.findIndex((o, k) => !used.has(k) && o.content === it.content)
302    if (j !== -1) used.add(j)
303    const prev = j === -1 ? undefined : old[j]
304    return {
305      key: prev?.key ?? `p${t}:${i}`,
306      ref: prev?.ref,
307      content: it.content,
308      status: it.status,
309      startedAt: it.status === 'pending' ? undefined : (prev?.startedAt ?? t),
310      endedAt: it.status === 'completed' ? (prev?.endedAt ?? t) : undefined,
311    }
312  })
313}
314
315function setStatus(task: Task, status: TaskStatus, t: number): Task {
316  if (task.status === status) return task
317  return {
318    ...task,
319    status,
320    startedAt: status === 'pending' ? undefined : (task.startedAt ?? t),
321    endedAt: status === 'completed' ? t : undefined,
322  }
323}
324
325// update_plan de la app: «items» sustituye la lista; «advance», «done» y «start» mueven pasos.
326function applyPlan(list: Task[], input: Record<string, unknown>, t: number): Task[] {
327  if (Array.isArray(input.items)) {
328    const items = (input.items as Record<string, unknown>[]).map(it => ({
329      content: String(it?.content ?? ''),
330      status: asStatus(it?.status),
331    }))
332    return fromItems(list, items, t)
333  }
334  const next = [...list]
335  const at = (n: unknown) => (typeof n === 'number' ? n - 1 : -1)
336  if (input.advance === true) {
337    const cur = next.findIndex(x => x.status === 'in_progress')
338    if (cur !== -1) next[cur] = setStatus(next[cur], 'completed', t)
339    const nxt = next.findIndex(x => x.status === 'pending')
340    if (nxt !== -1) next[nxt] = setStatus(next[nxt], 'in_progress', t)
341  }
342  if (Array.isArray(input.done)) {
343    for (const n of input.done) if (next[at(n)]) next[at(n)] = setStatus(next[at(n)], 'completed', t)
344  }
345  if (next[at(input.start)]) next[at(input.start)] = setStatus(next[at(input.start)], 'in_progress', t)
346  return next
347}
348
349// wrap_up cierra el turno: lo que seguía en curso queda hecho.
350function applyWrap(list: Task[], t: number): Task[] {
351  return list.map(x => (x.status === 'in_progress' ? setStatus(x, 'completed', t) : x))
352}
353
354function applyTodos(list: Task[], input: Record<string, unknown>, t: number): Task[] {
355  const todos = Array.isArray(input.todos) ? (input.todos as Record<string, unknown>[]) : []
356  return fromItems(
357    list,
358    todos.map(it => ({ content: String(it?.content ?? ''), status: asStatus(it?.status) })),
359    t,
360  )
361}
362
363function applyUpdate(list: Task[], input: Record<string, unknown>, t: number): Task[] {
364  const id = String(input.taskId ?? '')
365  if (input.status === 'deleted') return list.filter(x => x.ref !== id)
366  return list.map(x => {
367    if (x.ref !== id) return x
368    const renamed = typeof input.subject === 'string' && input.subject ? { ...x, content: input.subject } : x
369    return input.status ? setStatus(renamed, asStatus(input.status), t) : renamed
370  })
371}
372
373// Qué le hace una llamada a la lista de tareas; undefined si la herramienta no es de tareas.
374// «fresh» marca una lista nueva entera (abre el panel).
375function step(tool: string, input: Record<string, unknown>, result: unknown, t: number):
376  { apply: (list: Task[]) => Task[]; fresh: boolean } | undefined {
377  if (tool.endsWith(PLAN_TOOL) || tool === OWN_TOOL) {
378    return { apply: l => applyPlan(l, input, t), fresh: Array.isArray(input.items) }
379  }
380  if (tool.endsWith(WRAP_TOOL)) return { apply: l => applyWrap(l, t), fresh: false }
381  if (tool === 'TodoWrite') return { apply: l => applyTodos(l, input, t), fresh: true }
382  if (tool === 'TaskCreate') {
383    const made = (result as { task?: { id?: unknown } } | null | undefined)?.task?.id
384    const ref = typeof made === 'string' ? made : undefined
385    return {
386      apply: l => [...l, { key: `c${t}:${l.length}`, ref, content: String(input.subject ?? ''), status: 'pending' }],
387      fresh: false,
388    }
389  }
390  if (tool === 'TaskUpdate') return { apply: l => applyUpdate(l, input, t), fresh: false }
391  return undefined
392}
393
394async function track($: EngineInterface, tool: string, input: Record<string, unknown>, result: unknown) {
395  const t = await $.clock.now()
396  const s = step(tool, input, result, t)
397  if (!s) return
398  const before = await read($, tasks)
399
400  await update($, tasks, s.apply)
401  await update($, now, () => t)
402  if (s.fresh || before.length === 0) void $.ui.open({ id: PANE, title: TITLE }).catch(() => {})
403  const after = await read($, tasks)
404  if (after.some(x => x.status === 'in_progress')) startTicker($)
405}
406
407// La primera vez que el mod se carga en una sesión, rehace la lista con las llamadas que ya
408// hubo, para no arrancar vacío con un plan a medias. Una sola vez por sesión: si luego se
409// limpia la lista, una recarga no la resucita.
410async function replay($: EngineInterface) {
411  if (await read($, replayed)) return
412  await update($, replayed, () => true)
413  if ((await read($, tasks)).length) return
414  const t = await $.clock.now()
415  let list: Task[] = []
416  for (const m of await $.session.messages()) {
417    for (const use of m.toolUses ?? []) {
418      if (use.isError) continue
419      const s = step(use.tool, use.input, use.result, t)
420      if (s) list = s.apply(list)
421    }
422  }
423  if (!list.length) return
424  // El historial no trae horas: lo hecho queda sin duración y lo que sigue en curso cuenta desde ahora.
425  list = list.map(x => (x.status === 'completed' ? { ...x, startedAt: undefined, endedAt: undefined } : x))
426  await update($, tasks, () => list)
427  await update($, now, () => t)
428  if (list.some(x => x.status === 'in_progress')) startTicker($)
429}
430
431let seq = 0
432let ticker: Timer | undefined
433
434// Une la lista viva del motor con lo que guardamos al lanzar cada agente.
435async function sync($: EngineInterface): Promise<boolean> {
436  const infos: AgentInfo[] = await $.agent.list()
437  const t = await $.clock.now()
438  let active = false
439  await update($, bots, list => {
440    const taken = new Set(list.map(b => b.id).filter(Boolean))
441    const next = list.map(b => {
442      if (b.kind === 'shell') return b
443      let info = b.id ? infos.find(i => i.id === b.id) : undefined
444      if (!b.id) {
445        info = infos.find(i => !taken.has(i.id) && i.description === b.description)
446        if (info) taken.add(info.id)
447      }
448      if (!info) return b
449      const ended = isEnded(info.status)
450      return {
451        ...b,
452        id: info.id,
453        status: info.status,
454        endedAt: ended ? (b.endedAt ?? t) : undefined,
455      }
456    })
457    // Adopta los agentes vivos cuyo arranque el mod no vio (lanzados antes de cargarse).
458    // Su modelo no se conoce: salen en gris y sin hora de inicio exacta.
459    for (const info of infos) {
460      if (taken.has(info.id) || isEnded(info.status)) continue
461      taken.add(info.id)
462      next.push({
463        key: `adopted:${info.id}`,
464        kind: 'agent',
465        id: info.id,
466        description: info.description || info.name || info.type,
467        type: info.type,
468        model: '',
469        status: info.status,
470        startedAt: t,
471        action: '👀 Ya estaba en marcha',
472        calls: 0,
473      })
474    }
475    active = next.some(b => !isEnded(b.status))
476    return trim(next)
477  })
478  await update($, now, () => t)
479  return active
480}
481
482// Un solo reloj por carga del módulo; se apaga solo cuando no queda nadie activo.
483function startTicker($: EngineInterface) {
484  if (ticker) return
485  ticker = $.clock.every(1000, () => {
486    sync($)
487      .then(async active => {
488        const doing = (await read($, tasks)).some(x => x.status === 'in_progress')
489        if (!active && !doing && ticker) {
490          ticker.cancel()
491          ticker = undefined
492        }
493      })
494      .catch(() => {})
495  })
496}
497
498// Respaldo: al acabar un turno el motor puede entregar la lista de lo que sigue en marcha,
499// y lo que falta en ella ya terminó. No siempre la trae; la señal principal es el aviso.
500async function settleShells($: EngineInterface, inFlight: readonly { id: string }[] | undefined) {
501  if (!Array.isArray(inFlight)) return
502  const alive = new Set(inFlight.map(task => task.id))
503  const t = await $.clock.now()
504  await update($, bots, list =>
505    list.map(b =>
506      b.kind === 'shell' && !isEnded(b.status) && b.taskId && !alive.has(b.taskId)
507        ? { ...b, status: 'completed', endedAt: t }
508        : b,
509    ),
510  )
511}
512
513// Cuando un comando en segundo plano termina, la sesión recibe un aviso de tarea
514// (<task-notification>) con su id, la llamada que lo lanzó y cómo acabó.
515function tag(text: string, name: string): string | undefined {
516  const m = new RegExp(`<${name}>([^<]*)</${name}>`).exec(text)
517  return m?.[1]?.trim() || undefined
518}
519
520async function finishFromNotice($: EngineInterface, text: string) {
521  const taskId = tag(text, 'task-id')
522  const toolUseId = tag(text, 'tool-use-id')
523  const status = tag(text, 'status')
524  if (!taskId && !toolUseId) return
525  const ended = status === 'failed' || status === 'killed' ? status : 'completed'
526  const t = await $.clock.now()
527  await update($, bots, list =>
528    list.map(b =>
529      b.kind === 'shell' && !isEnded(b.status) &&
530      ((taskId && b.taskId === taskId) || (toolUseId && b.toolUseId === toolUseId))
531        ? { ...b, status: ended, endedAt: t }
532        : b,
533    ),
534  )
535}
536
537export const register: Register = on => {
538  on('session.start', async ($, e, next) => {
539    ticker = undefined
540    quotaClock = undefined
541    // La cuota se lee al arrancar; después llega sola con cada medición.
542    await $.session.usage().then(u => readLimits($, u.rateLimits)).catch(() => {})
543    await $.command.register({
544      name: 'bots',
545      description: 'Abre el panel de agentes y tareas',
546    })
547    await $.command.register({
548      name: 'tareas',
549      description: 'Abre el panel de agentes y tareas',
550    })
551    await $.tool.register(OWN_SPEC).catch(() => {})
552    await replay($).catch(() => {})
553    // Recoge también los agentes que ya corrían cuando el mod se cargó.
554    void sync($)
555      .then(active => {
556        if (active) startTicker($)
557      })
558      .catch(() => {})
559
560    return next(e)
561  })
562
563  on('command.run', { command: 'bots' }, async $ => {
564    await $.ui.open({ id: PANE, title: TITLE })
565    await sync($)
566
567    return { text: 'Panel de agentes y tareas abierto.' }
568  })
569
570  on('command.run', { command: 'tareas' }, async $ => {
571    await $.ui.open({ id: PANE, title: TITLE })
572    await sync($)
573
574    return { text: 'Panel de agentes y tareas abierto.' }
575  })
576
577  // Solo observa: el panel nunca debe impedir ni duplicar un lanzamiento, así que
578  // lo suyo va en try y, si aun así algo falla, el .catch deja pasar el original.
579  on('agent.spawn', async ($, e, next) => {
580    const key = `${e.tool_use_id}:${++seq}`
581    const model = e.fork ? e.parentModel : (e.model ?? e.parentModel)
582    try {
583      const bot: Bot = {
584        key,
585        description: e.description || e.subagentType,
586        type: e.subagentType,
587        model,
588        status: 'pending',
589        startedAt: await $.clock.now(),
590        action: '💭 Pensando…',
591        calls: 0,
592      }
593      await update($, bots, list => trim([...list, bot]))
594      void $.ui.open({ id: PANE, title: TITLE }).catch(() => {})
595      startTicker($)
596    } catch {}
597
598    const set = (patch: Partial<Bot>) =>
599      update($, bots, list => list.map(b => (b.key === key ? { ...b, ...patch } : b))).catch(() => {})
600    let ran
601    try {
602      ran = await next(e)
603    } catch (err) {
604      await set({ status: 'failed', action: '✗ No arrancó' })
605      throw err
606    }
607    if (ran.deny) await set({ status: 'failed', action: `✗ ${clip(ran.deny, 40)}` })
608    else await set({ id: ran.agentId, model: ran.model || model, status: 'running' })
609
610    return ran
611  }).catch(($, e, next) => next(e))
612
613  on('tool.call', async ($, e, next) => {
614    // Los parámetros de la herramienta vienen en el propio evento.
615    const input = e as unknown as Record<string, unknown>
616    const agentId = e.agentId
617
618    // La herramienta propia la atiende el mod mismo: actualiza la lista y le contesta al modelo.
619    if (e.tool === OWN_TOOL) {
620      if (agentId) return { result: 'La lista de tareas la mantiene la sesión principal.' }
621      if (!Array.isArray(input.items) && !input.advance && !Array.isArray(input.done) && typeof input.start !== 'number') {
622        return { result: 'Indica «items» (lista completa), «advance», «done» o «start».' }
623      }
624      await track($, e.tool, input, undefined)
625      const list = await read($, tasks)
626      const doing = list.find(x => x.status === 'in_progress')
627      const done = list.filter(x => x.status === 'completed').length
628      return { result: `Lista actualizada: ${done}/${list.length} hechas${doing ? `; en curso: ${doing.content}` : ''}.` }
629    }
630
631    if (agentId) {
632      const text = action(e.tool, input)
633      void update($, bots, list =>
634        list.map(b =>
635          b.id === agentId ? { ...b, action: text, calls: b.calls + 1, status: isEnded(b.status) ? b.status : 'running' } : b,
636        ),
637      ).catch(() => {})
638    }
639
640    const ran = await next(e)
641
642    // Un comando que quedó en segundo plano (pedido así o con Ctrl+B) recibe su propio robot.
643    if (e.tool === 'Bash' || e.tool === 'PowerShell') {
644      const result = ran.result as { backgroundTaskId?: unknown } | null | undefined
645      const taskId = result && typeof result.backgroundTaskId === 'string' ? result.backgroundTaskId : undefined
646      if (taskId) {
647        try {
648          const shell: Bot = {
649            key: `shell:${taskId}`,
650            kind: 'shell',
651            taskId,
652            toolUseId: e.tool_use_id,
653            description: clip(input.description, 60) || clip(input.command, 60) || e.tool,
654            type: e.tool,
655            model: '',
656            status: 'running',
657            startedAt: await $.clock.now(),
658            action: `⚙️ ${clip(input.command)}`,
659            calls: 0,
660          }
661          await update($, bots, list => trim([...list.filter(b => b.key !== shell.key), shell]))
662          void $.ui.open({ id: PANE, title: TITLE }).catch(() => {})
663          startTicker($)
664        } catch {}
665      }
666    }
667
668    // La lista de tareas es la de la sesión: las llamadas de un subagente son de su propia lista.
669    if (!agentId) {
670      try {
671        await track($, e.tool, input, ran.result)
672      } catch {}
673    }
674
675    return ran
676  }).catch(($, e, next) => next(e))
677
678  // Cada petición del hilo principal (los subagentes tienen su propio prefijo): qué hizo la caché con ella.
679  on('turn.step', async function* ($, e, next) {
680    if (e.agentId) return yield* next(e)
681    const inicio = await $.clock.now()
682    const r = yield* next(e)
683    try {
684      if (r.usage) {
685        const nueva: Muestra = {
686          modelo: r.usage.model || e.model,
687          inicio,
688          leido: r.usage.cache_read_input_tokens,
689          escrito: r.usage.cache_creation_input_tokens,
690          nuevo: r.usage.input_tokens,
691        }
692        const lista = [...(await read($, samples)), nueva].slice(-2)
693        await update($, samples, () => lista)
694        const plan = (await read($, limits)).map(l => ({ kind: l.kind, percentUsed: l.percentUsed }))
695        const env = {
696          force5m: await $.env.get('FORCE_PROMPT_CACHING_5M').catch(() => undefined),
697          ttlVar: await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL').catch(() => undefined),
698          enable1h: await $.env.get('ENABLE_PROMPT_CACHING_1H').catch(() => undefined),
699        }
700        const observado = observarTtl(lista[0] === nueva ? undefined : lista[0], nueva, undefined)
701        await update($, cacheTtl, () => observado ?? decidirTtl(env, plan).ttl)
702        await update($, now, () => inicio)
703      }
704    } catch {}
705    return r
706  })
707
708  on('session.measure', async ($, e, next) => {
709    if (e.changed.includes('rateLimits')) await readLimits($, e.rateLimits).catch(() => {})
710    return next(e)
711  }).catch(($, e, next) => next(e))
712
713  on('prompt.compose', async ($, e, next) => {
714    const ran = await next(e)
715    if (e.tools.some(HOST_LISTS)) return ran
716    return { sections: [...ran.sections, { id: 'agent-bots:tareas', text: OWN_PROMPT, scope: 'session' as const }] }
717  }).catch(($, e, next) => next(e))
718
719  on('prompt.submit', async ($, e, next) => {
720    if (e.origin.kind === 'task-notification') await finishFromNotice($, e.text).catch(() => {})
721    return next(e)
722  }).catch(($, e, next) => next(e))
723
724  on('classic.Stop', async ($, e, next) => {
725    await settleShells($, e.background_tasks).catch(() => {})
726    return next(e)
727  }).catch(($, e, next) => next(e))
728
729  on('classic.SubagentStop', async ($, e, next) => {
730    await settleShells($, e.background_tasks).catch(() => {})
731    return next(e)
732  }).catch(($, e, next) => next(e))
733
734  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
735    const els = $.ui.resolve(e)
736    const { Box, Text, Button } = els
737    const Svg = 'Svg' in els ? els.Svg : undefined
738    const list = await read($, bots)
739    const t = (await read($, now)) || (await $.clock.now())
740
741    const order: Record<BotMood, number> = { run: 0, sleep: 1, fail: 2, done: 3 }
742    const shown = [...list].sort(
743      (a, b) => order[mood(a.status)] - order[mood(b.status)] || b.startedAt - a.startedAt,
744    )
745    const count = (m: BotMood) => list.filter(b => mood(b.status) === m).length
746    const ended = list.some(b => isEnded(b.status))
747
748    const todo = await read($, tasks)
749    const done = todo.filter(x => x.status === 'completed').length
750    const doing = todo.filter(x => x.status === 'in_progress')
751    const left = todo.length - done - doing.length
752    const tmood: BotMood = doing.length ? 'run' : todo.length && done === todo.length ? 'done' : 'sleep'
753    const since = todo.reduce((min, x) => (x.startedAt !== undefined && x.startedAt < min ? x.startedAt : min), Infinity)
754    const pct = todo.length ? Math.round((done / todo.length) * 100) : 0
755
756    const quota = await read($, limits)
757    const clock = await $.clock.now()
758
759    // Caché de prompts: acierto de la última petición y cuánto le queda de vida (por minutos, para no distraer).
760    const recent = await read($, samples)
761    const last = recent[recent.length - 1]
762    const ttl = await read($, cacheTtl)
763    const left_ms = last ? restanteMs(last, ttl, clock) : 0
764    const hit = last ? Math.round(aciertos(last) * 100) : undefined
765    const cacheRamp: [string, string] = left_ms > 0 ? ['#5DCAA5', '#1D9E75'] : ['#B4B2A9', '#888780']
766    const cacheLife = !last
767      ? 'sin peticiones'
768      : last.leido + last.escrito === 0
769        ? 'sin caché'
770        : left_ms > 0
771          ? `⏱ ${left_ms < 60000 ? '<1 min' : `${Math.ceil(left_ms / 60000)} min`}`
772          : 'vencida'
773    // Corto a propósito: un texto más ancho que los de 5 h y semana ensancha la columna y agranda el anillo.
774    const cacheDetail = last ? `${tokens(last.leido)} leído` : ''
775
776    return (
777      <Box flexDirection="column" gap={1}>
778        {(quota.length > 0 || last) && (
779          <Box flexDirection="row" gap={2} justifyContent="space-around" alignItems="flex-start">
780            {QUOTA.map(({ kind, label, ramp }) => {
781              const l = quota.find(x => x.kind === kind)
782              if (!l) return null
783              const reset = resetIn(l.resetsAt, clock)
784              const hot = l.percentUsed >= 90 ? HOT[1] : l.percentUsed >= 70 ? WARN[1] : undefined
785              return (
786                <Box key={`q:${kind}`} flexDirection={Svg ? 'column' : 'row'} alignItems="center" gap={Svg ? 0 : 1}>
787                  {Svg ? (
788                    <Svg source={gauge(l.percentUsed, ramp, kind)} alt={`Cuota ${label}: ${Math.round(l.percentUsed)} %`} width={72} height={72} />
789                  ) : (
790                    <Text color={hot ?? ramp[1]}>{`${label.padEnd(6)} ${bar10(l.percentUsed)} ${Math.round(l.percentUsed)}%`}</Text>
791                  )}
792                  {Svg && <Text bold>{label}</Text>}
793                  {reset && <Text dimColor>{Svg ? `↻ ${reset}` : `· ↻ ${reset}`}</Text>}
794                </Box>
795              )
796            })}
797            {last && (
798              <Box key="q:cache" flexDirection={Svg ? 'column' : 'row'} alignItems="center" gap={Svg ? 0 : 1}>
799                {Svg ? (
800                  <Svg source={gauge(hit ?? 0, cacheRamp, 'cache', false)} alt={`Caché: ${hit ?? 0} % de aciertos`} width={72} height={72} />
801                ) : (
802                  <Text color={cacheRamp[1]}>{`${'Caché'.padEnd(6)} ${bar10(hit ?? 0)} ${hit ?? 0}%`}</Text>
803                )}
804                {Svg && <Text bold>Caché</Text>}
805                <Text dimColor>{Svg ? cacheLife : `· ${cacheLife}`}</Text>
806                {Svg && cacheDetail && <Text dimColor>{cacheDetail}</Text>}
807              </Box>
808            )}
809          </Box>
810        )}
811        <Text dimColor>
812          {count('run')} trabajando · {count('sleep')} esperando · {count('done')} listos
813          {count('fail') ? ` · ${count('fail')} fallidos` : ''}
814        </Text>
815        {list.length === 0 && (
816          <Text dimColor>Ningún agente ni comando todavía. Aparecerán aquí en cuanto se lance uno.</Text>
817        )}
818        {shown.map(b => {
819          const m = mood(b.status)
820          const [label, color] = LABEL[m]
821          const shell = b.kind === 'shell'
822          const who = shell ? 'Comando' : b.model ? modelName(b.model) : 'Modelo desconocido'
823          return (
824            <Box key={b.key} flexDirection="row" gap={1} alignItems="center">
825              {Svg ? (
826                <Svg source={robot(m, b.model, b.kind)} alt={`Robot ${label.toLowerCase()}`} width={56} height={56} isInteractive />
827              ) : (
828                <Text>{shell && m === 'run' ? '⚙️' : GLYPH[m]}</Text>
829              )}
830              <Box flexDirection="column" flexGrow={1} flexShrink={1}>
831                <Box flexDirection="row" justifyContent="space-between" gap={1}>
832                  <Text bold wrap="truncate-end">{b.description}</Text>
833                  <Button
834                    key={`x:${b.key}`}
835                    plain
836                    label="✕"
837                    onPress={() => {
838                      void update($, bots, l => l.filter(one => one.key !== b.key)).catch(() => {})
839                    }}
840                  />
841                </Box>
842                <Text wrap="truncate-end">
843                  <Text color={color}>{label}</Text>
844                  <Text dimColor>
845                    {' · '}{who} · {elapsed((b.endedAt ?? t) - b.startedAt)}{shell ? '' : ` · ${b.calls} herramientas`}
846                  </Text>
847                </Text>
848                <Text wrap="truncate-end" dimColor={m !== 'run'}>
849                  {m === 'done' ? '✓ Terminado' : m === 'sleep' && b.calls === 0 ? 'zzz… esperando' : b.action}
850                </Text>
851              </Box>
852            </Box>
853          )
854        })}
855        <Box flexDirection="row" justifyContent="space-between" alignItems="center">
856          <Text dimColor>
857            <Text color={RAMP.opus[1]}>●</Text> Opus <Text color={RAMP.sonnet[1]}>●</Text> Sonnet{' '}
858            <Text color={RAMP.haiku[1]}>●</Text> Haiku <Text color={SHELL[1]}>●</Text> Comando
859          </Text>
860        </Box>
861        {list.length > 0 && (
862          <Box flexDirection="row" gap={1} justifyContent="flex-end">
863            {ended && (
864              <Button
865                key="clear"
866                label="Quitar terminados"
867                onPress={() => {
868                  void update($, bots, l => l.filter(b => !isEnded(b.status))).catch(() => {})
869                }}
870              />
871            )}
872            <Button
873              key="clear-all"
874              label="Limpiar todo"
875              onPress={() => {
876                void update($, bots, () => []).catch(() => {})
877              }}
878            />
879          </Box>
880        )}
881        <Text dimColor>Tareas</Text>
882        <Box flexDirection="row" gap={1} alignItems="center">
883          {Svg ? (
884            <Svg source={robot(tmood, 'sonnet')} alt={`Robot ${LABEL[tmood][0].toLowerCase()}`} width={52} height={52} isInteractive />
885          ) : (
886            <Text>{GLYPH[tmood]}</Text>
887          )}
888          <Box flexDirection="column" flexGrow={1} flexShrink={1}>
889            <Text bold wrap="truncate-end">
890              {todo.length === 0 ? 'Sin tareas todavía' : `${done}/${todo.length} hechas · ${pct}%`}
891            </Text>
892            {todo.length > 0 && <Text color={tmood === 'done' ? '#639922' : '#378ADD'}>{bar(done, todo.length)}</Text>}
893            <Text dimColor wrap="truncate-end">
894              {todo.length === 0
895                ? 'Aparecerán aquí cuando se arme un plan.'
896                : `${doing.length} en curso · ${left} pendientes${since === Infinity ? '' : ` · ${elapsed(t - since)}`}`}
897            </Text>
898          </Box>
899        </Box>
900        {todo.map(x => {
901          const [label, color] = TASK_LABEL[x.status]
902          const active = x.status === 'in_progress'
903          return (
904            <Box key={x.key} flexDirection="row" gap={1} alignItems="center">
905              {Svg ? (
906                <Svg source={taskMark(x.status)} alt={label} width={22} height={22} />
907              ) : (
908                <Text>{TASK_GLYPH[x.status]}</Text>
909              )}
910              <Box flexDirection="column" flexGrow={1} flexShrink={1}>
911                <Text bold={active} dimColor={!active} wrap="truncate-end">
912                  {x.content}
913                </Text>
914                <Text wrap="truncate-end">
915                  <Text color={color}>{label}</Text>
916                  {x.startedAt !== undefined && <Text dimColor>{' · '}{elapsed((x.endedAt ?? t) - x.startedAt)}</Text>}
917                </Text>
918              </Box>
919            </Box>
920          )
921        })}
922        {todo.length > 0 && (
923          <Box flexDirection="row" gap={1} justifyContent="flex-end">
924            {done > 0 && (
925              <Button
926                key="tasks-clear-done"
927                label="Quitar hechas"
928                onPress={() => {
929                  void update($, tasks, l => l.filter(x => x.status !== 'completed')).catch(() => {})
930                }}
931              />
932            )}
933            <Button
934              key="tasks-clear"
935              label="Limpiar tareas"
936              onPress={() => {
937                void update($, tasks, () => []).catch(() => {})
938              }}
939            />
940          </Box>
941        )}
942      </Box>
943    )
944  })
945}
946
hooks/cache.ts 111 lines
1// Lógica pura de la caché de prompts (sin `$`). Ideas tomadas de
2// davila7/claude-code-templates · observability/prompt-cache-control (MIT,
3// commit 8da17d67), reescritas y simplificadas para el medidor:
4//   - la caché vive 5 min por defecto y 1 h si se pide; cada lectura la renueva
5//     gratis y la vida se cuenta desde el INICIO de la petición
6//   - prompt = input_tokens (sin caché) + cache_read + cache_creation
7//   - escribir cuesta 1,25× (5 min) o 2× (1 h) la entrada; leer ≈ 0,1×: lo caro es
8//     que venza con un contexto grande
9//   - un cambio del prefijo (modelo, esfuerzo, herramientas, system prompt,
10//     CLAUDE.md) hace que la siguiente petición escriba en vez de leer
11import type { Muestra, Ttl } from '../types'
12
13export type Consejo = {
14  tipo: 'fria' | 'sin-cache' | 'caliente' | 'pronto' | 'vencida' | 'fallo'
15  texto: string
16}
17
18const HOLGURA_MS = 10_000
19
20export const ttlMs = (ttl: Ttl) => (ttl === '1h' ? 3_600_000 : 300_000)
21export const tokensPrompt = (m: Muestra) => m.leido + m.escrito + m.nuevo
22export const aciertos = (m: Muestra) => (tokensPrompt(m) === 0 ? 0 : m.leido / tokensPrompt(m))
23
24export function restanteMs(m: Muestra, ttl: Ttl, ahora: number): number {
25  if (m.leido + m.escrito === 0) return 0
26  return Math.max(0, m.inicio + ttlMs(ttl) - ahora)
27}
28
29/** Duración que pide Claude Code según el entorno y la cuenta (orden de su documentación). */
30export function decidirTtl(
31  env: { force5m?: string; ttlVar?: string; enable1h?: string },
32  limites: { kind: string; percentUsed: number }[],
33): { ttl: Ttl; fuente: string } {
34  const si = (v?: string) => v === '1' || v?.toLowerCase() === 'true'
35  if (si(env.force5m)) return { ttl: '5m', fuente: 'FORCE_PROMPT_CACHING_5M' }
36  if (env.ttlVar === '5m' || env.ttlVar === '1h') return { ttl: env.ttlVar, fuente: 'CLAUDE_CODE_PROMPT_CACHE_TTL' }
37  if (si(env.enable1h)) return { ttl: '1h', fuente: 'ENABLE_PROMPT_CACHING_1H' }
38  const plan = limites.filter(l => l.kind === 'five_hour' || l.kind === 'seven_day')
39  if (plan.length === 0) return { ttl: '5m', fuente: 'sin suscripción visible' }
40  if (plan.some(l => l.percentUsed >= 100)) return { ttl: '5m', fuente: 'créditos de uso' }
41  return { ttl: '1h', fuente: 'suscripción' }
42}
43
44/**
45 * Lo que el tráfico dice de la duración: un acierto más de 5 min después de la
46 * petición anterior prueba 1 h; un fallo entre 5 y 60 min con el mismo modelo y
47 * un prompt que no encogió apunta a 5 min (más débil: un prefijo cambiado se ve igual).
48 */
49export function observarTtl(prev: Muestra | undefined, cur: Muestra, conocido: Ttl | undefined): Ttl | undefined {
50  if (!prev || prev.leido + prev.escrito === 0 || cur.modelo !== prev.modelo) return conocido
51  const hueco = cur.inicio - prev.inicio
52  const antes = tokensPrompt(prev)
53  if (hueco <= ttlMs('5m') + HOLGURA_MS) return conocido
54  if (cur.leido >= antes * 0.5) return '1h'
55  if (conocido === '1h') return conocido
56  const vencio = cur.escrito > 0 && tokensPrompt(cur) >= antes * 0.7 && hueco < ttlMs('1h') + HOLGURA_MS
57  return vencio ? '5m' : conocido
58}
59
60/** Por qué una petición que debía leer la caché la escribió; undefined si no falló. */
61export function causaFallo(prev: Muestra | undefined, cur: Muestra, ttl: Ttl): string | undefined {
62  if (!prev) return undefined
63  const antes = tokensPrompt(prev)
64  // Un prompt que encogió es /compact o /clear, no un fallo.
65  if (antes === 0 || tokensPrompt(cur) < antes * 0.7) return undefined
66  if (cur.leido >= antes * 0.5 || cur.escrito === 0) return undefined
67  if (cur.modelo !== prev.modelo) return 'cambió el modelo'
68  if (cur.inicio - prev.inicio > ttlMs(ttl)) return `había vencido (${ttl === '1h' ? '1 h' : '5 min'})`
69  return 'cambió el prefijo (esfuerzo, herramientas, system prompt o CLAUDE.md)'
70}
71
72export function aconsejar(
73  ultima: Muestra | undefined,
74  previa: Muestra | undefined,
75  ttl: Ttl,
76  ahora: number,
77  avisoMs: number,
78  compactarDesde: number,
79): Consejo {
80  if (!ultima) return { tipo: 'fria', texto: 'sin peticiones aún: la primera escribe la caché' }
81  if (ultima.leido + ultima.escrito === 0) return { tipo: 'sin-cache', texto: 'esta petición no usó caché' }
82  const resta = restanteMs(ultima, ttl, ahora)
83  const tam = tokensPrompt(ultima)
84  if (resta <= 0) {
85    return tam >= compactarDesde
86      ? { tipo: 'vencida', texto: `vencida: el próximo mensaje reescribe ${tokens(tam)}; /compact antes, o /clear si terminaste` }
87      : { tipo: 'vencida', texto: `vencida: solo ${tokens(tam)} por reconstruir, sigue` }
88  }
89  if (resta <= avisoMs) return { tipo: 'pronto', texto: 'vence pronto: cualquier mensaje la renueva gratis' }
90  const fallo = causaFallo(previa, ultima, ttl)
91  if (fallo) return { tipo: 'fallo', texto: `falló: ${fallo}` }
92  return { tipo: 'caliente', texto: 'caliente: sigue' }
93}
94
95export function tokens(n: number): string {
96  if (n < 1000) return String(n)
97  if (n < 100_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`
98  if (n < 1_000_000) return `${Math.round(n / 1000)}k`
99  return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
100}
101
102/** m:ss, o h:mm:ss desde una hora. */
103export function reloj(ms: number): string {
104  const t = Math.max(0, Math.ceil(ms / 1000))
105  const h = Math.floor(t / 3600)
106  const m = Math.floor((t % 3600) / 60)
107  const s = t % 60
108  const dos = (n: number) => String(n).padStart(2, '0')
109  return h > 0 ? `${h}:${dos(m)}:${dos(s)}` : `${m}:${dos(s)}`
110}
111
types/index.d.ts 66 lines
1export type BotMood = 'run' | 'sleep' | 'done' | 'fail'
2
3export type BotKind = 'agent' | 'shell'
4
5export type Bot = {
6  key: string
7  kind?: BotKind
8  taskId?: string
9  toolUseId?: string
10  id?: string
11  description: string
12  type: string
13  model: string
14  status: string
15  startedAt: number
16  endedAt?: number
17  action: string
18  calls: number
19}
20
21export type TaskStatus = 'pending' | 'in_progress' | 'completed'
22
23export type Task = {
24  key: string
25  // Id que el motor le dio con TaskCreate; las tareas de un plan no lo tienen.
26  ref?: string
27  content: string
28  status: TaskStatus
29  startedAt?: number
30  endedAt?: number
31}
32
33// Una ventana de cuota de la cuenta (5 h o semana) con la lectura anterior, para animar el cambio.
34export type Limit = {
35  kind: string
36  percentUsed: number
37  prevPercent: number
38  resetsAt?: string
39}
40
41export type Ttl = '5m' | '1h'
42
43// Una petición del hilo principal, como la reportó la API: de ella sale el estado de la caché de prompts.
44export type Muestra = {
45  modelo: string
46  // ms desde la época al empezar la petición: la vida de la caché cuenta desde aquí
47  inicio: number
48  leido: number
49  escrito: number
50  nuevo: number
51}
52
53declare module 'claude-code' {
54  interface PluginState {
55    'agent-bots': {
56      bots: Bot[]
57      tasks: Task[]
58      limits: Limit[]
59      replayed: boolean
60      now: number
61      muestras: Muestra[]
62      ttl: Ttl
63    }
64  }
65}
66