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

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.
| Mod | Qué hace | Dónde se ve |
|---|---|---|
agent-bots | Panel 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 prompts | App de escritorio (en terminal, versión de texto) |
medidor | Banda 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 regresiva | Terminal |
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.
/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.
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.
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.Cada mod trae sus pruebas:
cd agent-bots && claude plugin validate . && claude plugin test .
cd ../medidor && claude plugin validate .
MIT. La lógica de la caché toma ideas de otro proyecto MIT; ver NOTICE.md.
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.
hooks/register.tsx 946 lines1import { 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}
946hooks/cache.ts 111 lines1// 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}
111types/index.d.ts 66 lines1export 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