Elige haiku/sonnet/opus para cada subagente con reglas locales (0 tokens) y muestra los agentes en vivo: modelo, tarea, tokens, tiempo y log.

English | Español
A Claude Code mod. It picks haiku/sonnet/opus for each subagent using local rules (0 tokens) and shows agents live: model, task, tokens, cost, time and log (/squadagents).
In a Claude Code session:
/plugin install squadagents --marketplace LordSantox/squadagents
Answer y to add the marketplace, then choose the scope (user or project).
From a terminal:
claude plugin install squadagents --marketplace LordSantox/squadagents
Paste this into Claude Code:
Install the mod from https://github.com/LordSantox/squadagents following the instructions in its README.
model, the mod picks one by rules (hooks/router.ts): Plan→opus · Explore→haiku (sonnet if it verifies code) · validate/investigate/decide/plan→opus · tests/diagnose/implement→sonnet · short search/read→haiku · everything else→sonnet.model is kept only if it is the same price or cheaper than the rule's pick./squadagents panel: live subagents with model, task, tokens, estimated cost, savings vs opus, and log.Agent (which resets the counter). Exempt: Agent, SendMessage, AskUserQuestion, Skill, ToolSearch, TaskStop, ReadNotifications, mcp__ccd_*. Control it with /squadagents estricto on|off|<N> (N = limit).The UI text and notes are in Spanish.
/plugin uninstall squadagents
MIT
hooks/register.tsx 307 lines1import type { Register } from 'claude-code'
2
3import type { AgentRow, UiState } from '../types'
4import { summarySvg, agentSvg } from './svg'
5import { fmtDur, fmtTok, bar, fmtUsd, bumpStreak, shouldBlock, strictDeny, parseStrict, groupAgents, pickModel, resolveModel, shortModel, spawnNote, stepCost } from './router'
6
7const PANE = 'squadagents'
8const AGENTS = { plugin: 'squadagents', key: 'agents' } as const
9const MAX_ROWS = 30
10const MAX_LOG = 12
11const MAIN = { plugin: 'squadagents', key: 'main' } as const
12const UI = { plugin: 'squadagents', key: 'ui' } as const
13const FILTERS: Array<[UiState['filter'], string]> = [['todos', 'Todos'], ['running', 'Corriendo'], ['done', 'Terminados'], ['failed', 'Fallados']]
14const STOP_MSG = 'Detente despues del paso actual y reporta tu estado.'
15
16// Lee y escribe con ifVersion (reintenta si otro evento escribio en medio).
17async function mutate($: any, fn: (list: AgentRow[]) => AgentRow[]) {
18 for (let i = 0; i < 5; i++) {
19 const held = await $.state.get(AGENTS)
20 const r = await $.state.set(AGENTS, fn(held.value ?? []), { ifVersion: held.version })
21 if (r.isSet) return
22 }
23}
24
25// Agentes que no pasaron por agent.spawn (retomados con SendMessage, o de antes de
26// cargar el mod): se dan de alta con lo que diga $.agent.list() al ver su actividad.
27async function patch($: any, id: string, fn: (a: AgentRow) => AgentRow, model?: string) {
28 const list: AgentRow[] = (await $.state.get(AGENTS)).value ?? []
29 if (!list.some(a => a.id === id)) {
30 let info: any = null
31 try { info = ((await $.agent.list()) || []).find((x: any) => x.id === id) } catch {}
32 const row: AgentRow = {
33 id, name: info?.name || info?.type || 'agente', model: model ? shortModel(model) : '?',
34 task: info?.description || '', why: 'retomado', tokens: 0, cacheRead: 0,
35 startedAt: Date.now(), status: 'running', log: ['retomado'],
36 }
37 await mutate($, l => (l.some(a => a.id === id) ? l : [...l, row].slice(-MAX_ROWS)))
38 }
39 await mutate($, l => l.map(a => (a.id === id ? fn(a) : a)))
40}
41
42const total = (list: AgentRow[]) => list.reduce((s, a) => s + a.tokens, 0)
43const usd = (list: AgentRow[]) => list.reduce((s, a) => s + (a.cost || 0), 0)
44const saved = (list: AgentRow[]) => list.reduce((s, a) => s + Math.max(0, (a.opusCost || 0) - (a.cost || 0)), 0)
45
46async function refreshStatus($: any) {
47 const list: AgentRow[] = (await $.state.get(AGENTS)).value ?? []
48 const vivos = list.filter(a => a.status === 'running').length
49 $.ui.status(list.length ? `🤖 ${vivos}/${list.length} · ${fmtUsd(usd(list))} · ahorro ${fmtUsd(saved(list))}` : undefined)
50}
51
52const getStrict = async ($: any): Promise<[boolean, number]> => [
53 ((await $.store.get('strict')) as boolean | undefined) ?? true,
54 ((await $.store.get('strictLimit')) as number | undefined) ?? 6,
55]
56
57export const register: Register = on => {
58 on('session.start', async ($, e, next) => {
59 await $.command.register({ name: 'squadagents', description: 'Panel de subagentes: modelo, tarea, tokens, tiempo y log' })
60 return next(e)
61 })
62
63 on('command.run', { command: 'squadagents' }, async ($, e) => {
64 const args = String((e as any).args ?? '').trim()
65 if (/^estricto/i.test(args)) {
66 const p = parseStrict(args)
67 if (!p) return { text: 'Uso: /squadagents estricto on|off|<N>' }
68 if (p.strict !== undefined) await ($ as any).store.set('strict', p.strict)
69 if (p.limit !== undefined) await ($ as any).store.set('strictLimit', p.limit)
70 const [st, lim] = await getStrict($)
71 return { text: `squadagents: modo estricto ${st ? 'ON' : 'off'} (límite ${lim})` }
72 }
73 await $.ui.open({ id: PANE, title: 'Squad' })
74 return { text: 'Panel squadagents abierto.' }
75 })
76
77 // Elige el modelo antes de arrancar el subagente. Respeta un `model` explicito y los forks.
78 on('agent.spawn', async ($, e, next) => {
79 // Las reglas mandan: un `model` explicito solo se respeta si es igual o mas barato.
80 let why = 'fork'
81 let model = e.model
82 if (!e.fork) {
83 const r = resolveModel(pickModel(e.subagentType, `${e.description}\n${e.prompt}`), e.model, e.parentModel)
84 model = r.model
85 why = r.why
86 }
87 const r = await next(e.fork ? e : { ...e, model })
88 if ('deny' in r && r.deny) return r
89 const row: AgentRow = {
90 id: r.agentId ?? e.tool_use_id,
91 name: e.name || e.subagentType,
92 model: shortModel(r.model || model || e.parentModel),
93 task: e.description,
94 why: e.fork ? 'fork (hereda modelo)' : why,
95 tokens: 0,
96 cacheRead: 0,
97 startedAt: Date.now(),
98 status: 'running',
99 log: [`inicio · ${why}`],
100 }
101 await mutate($, list => [...list, row].slice(-MAX_ROWS))
102 await refreshStatus($)
103 void $.ui.open({ id: PANE, title: 'Squad' })
104 return r
105 })
106
107 // Tras cada Agent, una nota (solo para el modelo) con el modelo que se eligio.
108 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
109 const r = await next(e)
110 if (r.deny !== undefined) return r
111 const a = e as any
112 if (a.subagent_type === 'fork') return r
113 const note = spawnNote(
114 pickModel(a.subagent_type ?? 'general-purpose', `${a.description ?? ''}\n${a.prompt ?? ''}`),
115 a.model,
116 )
117 return { ...r, context: [...(r.context ?? []), note] }
118 })
119
120 // Aviso de racha: solo agente principal (sin agentId). Agent reinicia; nunca bloquea.
121 on('tool.call', async ($, e, next) => {
122 const ev = e as any
123 const name = String(ev.tool ?? ev.toolName ?? ev.name ?? '')
124 const key = { plugin: 'squadagents', key: 'streak' } as const
125 if (!ev.agentId) {
126 const [strict, limit] = await getStrict($)
127 const d = ((await ($ as any).state.get(key)).value ?? { direct: 0 }).direct
128 if (shouldBlock(name, d, strict, limit)) return { deny: strictDeny(d) }
129 }
130 const r = await next(e)
131 if (ev.agentId || r.deny !== undefined) return r
132 const cur = ((await ($ as any).state.get(key)).value ?? { browser: 0, work: 0, direct: 0 })
133 const direct = cur.direct
134 const { s, note } = name === 'Agent' ? { s: { browser: 0, work: 0, direct }, note: undefined } : bumpStreak(cur, name)
135 await ($ as any).state.set(key, s)
136 return note ? { ...r, context: [...(r.context ?? []), note] } : r
137 })
138
139 // Retomar un agente con SendMessage no pasa por agent.spawn: se registra aqui.
140 on('tool.call', { tool: 'SendMessage' }, async ($, e, next) => {
141 const r = await next(e)
142 const to = String((e as any).to ?? '')
143 if (to && r.deny === undefined) {
144 try {
145 const info = ((await $.agent.list()) || []).find((x: any) => x.id === to || x.name === to)
146 if (info) {
147 await patch($, info.id, a => ({ ...a, status: 'running', endedAt: undefined,
148 log: [...a.log, 'retomado (SendMessage)'].slice(-MAX_LOG) }))
149 await refreshStatus($)
150 void $.ui.open({ id: PANE, title: 'Squad' })
151 }
152 } catch {}
153 }
154 return r
155 })
156
157 // Tokens por paso del subagente: dato que el motor ya trae, sin llamadas extra.
158 on('turn.step', async function* ($, e, next) {
159 const r = yield* next(e)
160 if (!e.agentId && r && 'usage' in r && r.usage) {
161 const c = stepCost(e.model, r.usage).cost
162 for (let i = 0; i < 5; i++) {
163 const h = await ($ as any).state.get(MAIN)
164 const v = h.value ?? { cost: 0 }
165 if ((await ($ as any).state.set(MAIN, { cost: v.cost + c }, { ifVersion: h.version })).isSet) break
166 }
167 }
168 if (e.agentId && r && 'usage' in r && r.usage) {
169 const u = r.usage
170 const c = stepCost(e.model, u)
171 await patch($, e.agentId, a => ({
172 ...a,
173 model: a.model === '?' ? shortModel(e.model) : a.model,
174 // Retomado tras terminar: vuelve a estar trabajando.
175 status: 'running', endedAt: undefined,
176 tokens: a.tokens + u.input_tokens + u.output_tokens + u.cache_creation_input_tokens,
177 cacheRead: a.cacheRead + u.cache_read_input_tokens,
178 cost: (a.cost || 0) + c.cost,
179 opusCost: (a.opusCost || 0) + c.opus,
180 }), e.model)
181 await refreshStatus($)
182 }
183 return r
184 })
185
186
187 on('turn.complete', async ($, e, next) => {
188 const r = await next(e)
189 if (e.agentId) {
190 const ok = e.reason === 'answer'
191 await patch($, e.agentId, a => ({
192 ...a, status: ok ? 'done' : 'failed', endedAt: Date.now(),
193 log: [...a.log, ok ? 'terminado' : `fin: ${e.reason}`].slice(-MAX_LOG),
194 }))
195 await refreshStatus($)
196 }
197 return r
198 })
199
200 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
201 const { Box, Text, Button } = $.ui.resolve(e)
202 const Svg = (e as any).surface === 'desktop' ? ($.ui.resolve(e) as any).Svg : undefined
203 const list: AgentRow[] = (await $.state.get(AGENTS)).value ?? []
204 const ui: UiState = (await $.state.get(UI)).value ?? { filter: 'todos', open: [] }
205 const main = (await ($ as any).state.get(MAIN)).value ?? { cost: 0 }
206 const direct = ((await ($ as any).state.get({ plugin: 'squadagents', key: 'streak' })).value ?? { direct: 0 }).direct
207 const [strict, strictLim] = await getStrict($)
208 const now = Date.now()
209 const frame = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'[Math.floor(now / 200) % 10] // sin elemento Spinner en la API de paneles: glifo por reloj
210 const color = (m: string) => (m === 'opus' ? 'magenta' : m === 'sonnet' ? 'cyan' : 'green')
211 const setUi = (fn: (u: UiState) => UiState) => $.state.set(UI, fn(ui))
212 const shown = list.filter(a => ui.filter === 'todos' || a.status === ui.filter)
213 const groups = groupAgents(shown.slice().reverse())
214 if (Svg) {
215 const maxTok = Math.max(1, ...shown.map(a => a.tokens))
216 return (
217 <Box flexDirection="column">
218 <Svg isInteractive width={560} height={64} alt="Resumen de costo y ahorro" source={summarySvg(list.length, usd(list), saved(list), main.cost, direct, strict, strictLim)} />
219 <Box>
220 {FILTERS.map(([f, label]) => (
221 <Button key={`f-${f}`} variant={ui.filter === f ? 'primary' : undefined} onPress={() => void setUi(u => ({ ...u, filter: f }))}>{label}</Button>
222 ))}
223 <Button key="clear" dimColor onPress={() => void mutate($, l => l.filter(a => a.status !== 'done')).then(() => refreshStatus($))}>Limpiar terminados</Button>
224 </Box>
225 {shown.length === 0 && <Text dimColor>{list.length ? 'Nada con ese filtro.' : 'Sin subagentes todavia. Se listan al lanzarse.'}</Text>}
226 {shown.slice().reverse().map(a => {
227 const isOpen = ui.open.includes(a.id)
228 return (
229 <Box flexDirection="column" marginTop={1}>
230 <Svg isInteractive width={560} height={96} alt={`${a.name} ${a.model} ${a.status}`} source={agentSvg(a, maxTok, now)} />
231 <Box>
232 <Button key={`x-${a.id}`} plain onPress={() => void setUi(u => ({ ...u, open: isOpen ? u.open.filter(i => i !== a.id) : [...u.open, a.id] }))}>{isOpen ? '▾ detalle' : '▸ detalle'}</Button>
233 {a.status === 'running' && (
234 <Button key={`s-${a.id}`} dimColor onPress={() => void $.session.send({ to: { agentId: a.id }, text: STOP_MSG })}>Pedir que frene</Button>
235 )}
236 </Box>
237 {isOpen && (
238 <Box flexDirection="column">
239 <Text dimColor> modelo: {a.why} · cache: {fmtTok(a.cacheRead)} tok · inicio {new Date(a.startedAt).toLocaleTimeString()}</Text>
240 {a.log.map(l => <Text dimColor> · {l}</Text>)}
241 </Box>
242 )}
243 </Box>
244 )
245 })}
246 </Box>
247 )
248 }
249 return (
250 <Box flexDirection="column">
251 <Text color="yellow">principal: {fmtUsd(main.cost)} · acciones directas: {direct}{strict ? ` · estricto ≤${strictLim}` : ' · estricto off'}</Text>
252 <Text bold>
253 {list.length} agentes · {fmtUsd(usd(list))} · ahorro {fmtUsd(saved(list))} vs opus
254 </Text>
255 <Box>
256 {FILTERS.map(([f, label]) => (
257 <Button key={`f-${f}`} variant={ui.filter === f ? 'primary' : undefined} onPress={() => void setUi(u => ({ ...u, filter: f }))}>{label}</Button>
258 ))}
259 <Button key="clear" dimColor onPress={() => void mutate($, l => l.filter(a => a.status !== 'done')).then(() => refreshStatus($))}>Limpiar terminados</Button>
260 </Box>
261 {shown.length === 0 && <Text dimColor>{list.length ? 'Nada con ese filtro.' : 'Sin subagentes todavia. Se listan al lanzarse.'}</Text>}
262 {groups.map(([g, items]) => {
263 const fin = items.filter(a => a.status !== 'running').length
264 return (
265 <Box flexDirection="column" marginTop={1}>
266 <Text bold>{g} <Text dimColor>{bar(fin, items.length)} {fin}/{items.length} · {fmtUsd(usd(items))}</Text></Text>
267 {items.map(a => {
268 const isOpen = ui.open.includes(a.id)
269 return (
270 <Box flexDirection="column">
271 <Box>
272 <Button key={`x-${a.id}`} plain onPress={() => void setUi(u => ({ ...u, open: isOpen ? u.open.filter(i => i !== a.id) : [...u.open, a.id] }))}>{isOpen ? '▾' : '▸'}</Button>
273 <Text>
274 {' '}
275 <Text color={a.status === 'running' ? 'yellow' : a.status === 'done' ? 'green' : 'red'}>
276 {a.status === 'running' ? `${frame} ` : a.status === 'done' ? '✓ ' : '✗ '}
277 </Text>
278 <Text bold>{a.name}</Text>
279 <Text> · </Text>
280 <Text color={color(a.model)}>{a.model}</Text>
281 <Text dimColor>
282 {' '}· {fmtUsd(a.cost || 0)}{(a.opusCost || 0) > (a.cost || 0) ? ` (ahorro ${fmtUsd((a.opusCost || 0) - (a.cost || 0))})` : ''} · {fmtTok(a.tokens)} tok · {fmtDur((a.endedAt ?? now) - a.startedAt)}
283 </Text>
284 </Text>
285 {a.status === 'running' && (
286 <Button key={`s-${a.id}`} dimColor onPress={() => void $.session.send({ to: { agentId: a.id }, text: STOP_MSG })}>Pedir que frene</Button>
287 )}
288 </Box>
289 <Text dimColor> {a.task}</Text>
290 {a.why?.startsWith('forzado') && <Text color="yellow"> ⚠ {a.why}</Text>}
291 {isOpen && (
292 <Box flexDirection="column">
293 <Text dimColor> modelo: {a.why} · cache: {fmtTok(a.cacheRead)} tok · inicio {new Date(a.startedAt).toLocaleTimeString()}</Text>
294 {a.log.map(l => <Text dimColor> · {l}</Text>)}
295 </Box>
296 )}
297 </Box>
298 )
299 })}
300 </Box>
301 )
302 })}
303 </Box>
304 )
305 })
306}
307hooks/svg.ts 78 lines1import type { AgentRow } from '../types'
2import { fmtDur, fmtTok, fmtUsd } from './router'
3
4const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c] as string))
5const cut = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
6const W = 560
7
8// Tema claro/oscuro por prefers-color-scheme dentro del SVG (marco aislado, sin script).
9const STYLE = `<style>
10:root{--card:#f8fafc;--bd:#e2e8f0;--tx:#0f172a;--mu:#64748b;--trk:#e2e8f0}
11@media (prefers-color-scheme:dark){:root{--card:#111a2e;--bd:#24304a;--tx:#e5e7eb;--mu:#94a3b8;--trk:#24304a}}
12text{font-family:system-ui,-apple-system,Segoe UI,sans-serif;fill:var(--tx);font-size:13px}
13.mu{fill:var(--mu);font-size:11px}.big{font-size:22px;font-weight:700}.b{font-weight:600}
14.card{fill:var(--card);stroke:var(--bd)}.k:hover{stroke:var(--mu)}
15.ic{fill:none;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
16</style>`
17
18// Iconos 16x16 dibujados a mano; solo paths, 100% locales al render del panel.
19const ICONS: Record<string, string> = {
20 coin: '<circle cx="8" cy="8" r="6"/><path d="M8 4.5v7M10 6.3C9.5 5.6 6 5.4 6 7.2 6 9 10 8.2 10 10 10 11.6 6.6 11.5 6 10.5"/>',
21 bolt: '<path d="M9 1.5 3.5 9H8l-1 5.5L12.5 7H8z"/>',
22 clock: '<circle cx="8" cy="8" r="6"/><path d="M8 4.5V8l2.5 1.5"/>',
23 shield: '<path d="M8 1.5 13 3.5v4c0 3-2 5.3-5 7-3-1.7-5-4-5-7v-4z"/><path d="M5.8 8l1.6 1.6L10.5 6.5"/>',
24 lock: '<rect x="3.5" y="7" width="9" height="7" rx="1.5"/><path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2"/>',
25 check: '<circle cx="8" cy="8" r="6"/><path d="M5.3 8.2l1.9 1.9 3.6-3.8"/>',
26 cross: '<circle cx="8" cy="8" r="6"/><path d="M5.8 5.8l4.4 4.4M10.2 5.8l-4.4 4.4"/>',
27 play: '<circle cx="8" cy="8" r="6"/><path d="M6.8 5.5v5l4-2.5z"/>',
28 explore: '<circle cx="7" cy="7" r="4.5"/><path d="M10.3 10.3 14 14"/>',
29 plan: '<path d="M3 3.5h10M3 8h10M3 12.5h6"/>',
30 code: '<path d="M5.5 4.5 2 8l3.5 3.5M10.5 4.5 14 8l-3.5 3.5"/>',
31 bot: '<rect x="3" y="5" width="10" height="8" rx="2"/><path d="M8 5V2.5M6 9h.01M10 9h.01"/>',
32}
33const icon = (n: string, x: number, y: number, col: string) =>
34 `<g class="ic" transform="translate(${x} ${y})" stroke="${col}">${ICONS[n]}</g>`
35const kind = (name: string) => (/explor/i.test(name) ? 'explore' : /plan/i.test(name) ? 'plan' : /general|code|impl/i.test(name) ? 'code' : 'bot')
36
37const MC: Record<string, string> = { opus: '#a855f7', sonnet: '#06b6d4', haiku: '#22c55e' }
38const ST: Record<string, string> = { running: '#f59e0b', done: '#22c55e', failed: '#ef4444' }
39const wrap = (h: number, body: string) =>
40 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${h}" width="${W}" height="${h}">${STYLE}${body}</svg>`
41
42export function summarySvg(n: number, cost: number, saved: number, main: number, direct: number, strict: boolean, limit: number): string {
43 const card = (x: number, w: number, ic: string, label: string, val: string, col: string, tip: string) =>
44 `<g><title>${esc(tip)}</title><rect class="card k" x="${x}" y="0.5" width="${w}" height="63" rx="10"/>` +
45 icon(ic, x + 12, 10, col) +
46 `<text class="mu" x="${x + 34}" y="22">${esc(label)}</text><text class="big" x="${x + 12}" y="50" style="fill:${col}">${esc(val)}</text></g>`
47 const lock = `<g><title>${esc(strict ? `Modo estricto: bloquea tras ${limit} acciones directas` : 'Modo estricto desactivado')}</title>${icon(strict ? 'lock' : 'shield', W - 30, 10, strict ? '#f59e0b' : '#94a3b8')}</g>`
48 return wrap(64,
49 card(0, 176, 'coin', `Costo (${n} agentes)`, fmtUsd(cost), '#06b6d4', 'Costo total de subagentes') +
50 card(188, 176, 'bolt', 'Ahorro vs opus', fmtUsd(saved), '#22c55e', 'Lo que habria costado en opus, menos lo real') +
51 card(376, 184, 'shield', 'Principal', fmtUsd(main), '#f59e0b', `acciones directas: ${direct}`) + lock)
52}
53
54export function agentSvg(a: AgentRow, maxTok: number, now: number): string {
55 const mc = MC[a.model] ?? '#94a3b8'
56 const sc = ST[a.status] ?? '#94a3b8'
57 const save = Math.max(0, (a.opusCost || 0) - (a.cost || 0))
58 const bw = Math.max(2, Math.round((a.tokens / Math.max(1, maxTok)) * 300))
59 const stIcon = a.status === 'running'
60 ? `<g>${icon('play', 10, 12, sc)}<circle cx="18" cy="20" r="3" fill="${sc}"><animate attributeName="opacity" values="1;.2;1" dur="1.2s" repeatCount="indefinite"/></circle></g>`
61 : icon(a.status === 'done' ? 'check' : 'cross', 10, 12, sc)
62 const chip = (x: number, ic: string, t: string, tip: string) =>
63 `<g><title>${esc(tip)}</title><rect x="${x}" y="60" width="${t.length * 6.2 + 34}" height="22" rx="11" style="fill:var(--bd)"/>` +
64 `<g class="ic" transform="translate(${x + 6} 64) scale(.8)" stroke="var(--mu)">${ICONS[ic]}</g>` +
65 `<text class="mu" x="${x + 24}" y="75">${esc(t)}</text></g>`
66 const c1 = fmtUsd(a.cost || 0), c2 = `${fmtTok(a.tokens)} tok`, c3 = fmtDur((a.endedAt ?? now) - a.startedAt)
67 const x2 = 14 + c1.length * 6.2 + 34 + 8, x3 = x2 + c2.length * 6.2 + 34 + 8
68 return wrap(96,
69 `<g><title>${esc(a.task || a.name)}</title><rect class="card k" x="0.5" y="0.5" width="${W - 1}" height="94" rx="12"/>` +
70 stIcon + icon(kind(a.name), 32, 10, 'var(--mu)') + `<text class="b" x="54" y="25">${esc(cut(a.name, 32))}</text>` +
71 `<rect x="${W - 86}" y="9" width="70" height="22" rx="11" fill="${mc}" fill-opacity=".18" stroke="${mc}"/>` +
72 `<text class="b" x="${W - 51}" y="24" text-anchor="middle" style="fill:${mc};font-size:12px">${esc(a.model)}</text>` +
73 `<text class="mu" x="14" y="50">${esc(cut(a.task || '', 78))}</text>` +
74 chip(14, 'coin', c1, 'costo') + chip(x2, 'bot', c2, `tokens (cache ${fmtTok(a.cacheRead)})`) + chip(x3, 'clock', c3, 'duracion') +
75 (save > 0 ? icon('bolt', W - 110, 63, '#22c55e') + `<text class="mu" x="${W - 14}" y="75" text-anchor="end" style="fill:#22c55e">ahorro ${esc(fmtUsd(save))}</text>` : '') +
76 `<rect x="14" y="87" width="${bw}" height="3" rx="1.5" fill="${mc}"><title>${esc(fmtTok(a.tokens))} tok</title></rect></g>`)
77}
78hooks/router.ts 159 lines1// Plantilla de decision: elige el modelo MAS BARATO que sirve para la tarea.
2// Reglas locales (regex), cero llamadas al modelo: decidir no gasta tokens.
3export type Pick = { model: 'haiku' | 'sonnet' | 'opus'; why: string }
4
5// Por tipo de agente: lo que define el tipo manda (Explore se trata aparte).
6const BY_TYPE: Record<string, Pick> = {
7 'claude-code-guide': { model: 'haiku', why: 'consulta de documentacion' },
8 'statusline-setup': { model: 'haiku', why: 'configuracion simple' },
9 Plan: { model: 'opus', why: 'tipo Plan: planificar' },
10}
11
12// Opus para pensar: planificar, disenar, decidir, y VALIDAR/INVESTIGAR a fondo
13// (cruzar codigo contra docs o un backtest, buscar por que algo diverge). Un
14// haiku en esas tareas tiende a confirmar lo pedido sin encontrar el problema.
15const OPUS = /\b(planif|plan de|arquitect|dise[nñ]|design|decid|decision|estrateg|trade.?off|audit|security review|revisi[oó]n profunda|root cause|causa ra[ií]z|verific|verify|valid|investig|diverg|reproduc|compar[ae]\w* contra|compare against|contrast)/i
16// Ejecutar y diagnosticar (tests, fallos, implementar): sonnet, nunca haiku aunque diga "lista".
17const SONNET = /\b(pytest|tests?\b|fallo|fallid|failing|diagnos|debug|traceback|implement|arregl|fix|entregables|migraci[oó]n|endpoint|componente)/i
18// Haiku para lo mecanico: buscar, leer, listar, resumir, contar, traducir.
19const HAIKU = /\b(busca|buscar|search|find|grep|localiza|locate|lista|listar|list|lee|leer|read|resum|summar|explor|cuenta|count|traduc|translat|renombra|rename|formatea|format)/i
20
21// Pruebas de navegador con guion (clic, captura, recorrer UI): mecanico, haiku.
22const BROWSER = /(navegador|browser|cl[ií]c|click|captura|screenshot|recorrer la UI|verificar en pantalla|playwright)/i
23const WHY = /(por qu[eé]|why)/i
24
25export function pickModel(subagentType: string, text: string): Pick {
26 const t = BY_TYPE[subagentType]
27 if (t) return t
28 if (subagentType === 'Explore') {
29 // Explore es solo lectura: haiku para ubicar, sonnet si debe verificar contra el codigo.
30 return OPUS.test(text)
31 ? { model: 'sonnet', why: 'Explore que verifica/cruza contra el codigo' }
32 : { model: 'haiku', why: 'tipo Explore: buscar/leer' }
33 }
34 if (BROWSER.test(text)) {
35 return SONNET.test(text) || WHY.test(text)
36 ? { model: 'sonnet', why: 'navegador que ademas diagnostica' }
37 : { model: 'haiku', why: 'pruebas de navegador con guion' }
38 }
39 // La intencion la marca la descripcion (1a linea). Un prompt de IMPLEMENTACION que
40 // al final dice "verifica con tests" no es una tarea de pensar: manda sonnet.
41 const desc = text.split(/\r?\n/)[0] ?? ''
42 if (OPUS.test(desc) && !SONNET.test(desc)) return { model: 'opus', why: 'pensar/validar/investigar/decidir' }
43 if (SONNET.test(text)) return { model: 'sonnet', why: 'ejecutar/diagnosticar/implementar' }
44 if (OPUS.test(text)) return { model: 'opus', why: 'pensar/validar/investigar/decidir' }
45 if (HAIKU.test(text) && text.length < 4000) return { model: 'haiku', why: 'tarea mecanica corta' }
46 return { model: 'sonnet', why: 'implementar/editar/depurar' }
47}
48
49export const shortModel = (m: string) =>
50 /haiku/i.test(m) ? 'haiku' : /sonnet/i.test(m) ? 'sonnet' : /opus/i.test(m) ? 'opus' : m
51
52export const fmtTok = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`)
53
54export const fmtDur = (ms: number) => {
55 const s = Math.max(0, Math.round(ms / 1000))
56 return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${s}s`
57}
58
59// Lo que el modelo principal lee en su system prompt. Sin esto, una sesion
60// asumio que los subagentes heredaban su modelo y le informo mal al usuario.
61export const PROMPT_SECTION = [
62 '# Mod squadagents (activo)',
63 'Cuando lanzas un subagente con la herramienta Agent SIN el parametro `model`, el mod squadagents elige el modelo (haiku/sonnet/opus) con reglas sobre el tipo y el prompt; NO hereda el modelo de esta sesion.',
64 'Reglas: Plan -> opus; Explore -> haiku (sonnet si debe verificar contra el codigo); planificar/disenar/decidir/validar/investigar/comparar contra -> opus; tests/diagnostico/implementar -> sonnet; buscar/leer/listar corto -> haiku; pruebas de navegador con guion (clic/captura/recorrer UI) -> haiku, sonnet si ademas diagnostica (se evalua antes que opus); resto -> sonnet.',
65 'Si la tarea exige criterio y el prompt no lo deja claro, pasa `model` explicito: el mod lo respeta. Si llevas muchas acciones directas seguidas (navegador >3, Bash/Edit/Write >5) el mod te avisa: delega a un subagente. Tras cada Agent llega una nota "squadagents:" con el modelo elegido; usa ESE dato si el usuario pregunta, no lo supongas.',
66].join('\n')
67
68const RANK: Record<string, number> = { haiku: 0, sonnet: 1, opus: 2 }
69const rankOf = (m: string) => RANK[shortModel(m)] ?? 2 // desconocido o full id raro = el caro
70
71// Las reglas mandan: un `model` explicito solo puede BAJAR el costo, nunca subirlo.
72// Asi el agente principal no puede saltarse la plantilla pidiendo opus "por si acaso".
73export function resolveModel(p: Pick, explicit?: string, parentModel?: string): { model: string; why: string } {
74 if (!explicit) return { model: p.model, why: p.why }
75 const asked = explicit === 'inherit' ? (parentModel || 'opus') : explicit
76 if (rankOf(asked) <= (RANK[p.model] ?? 2)) return { model: shortModel(asked), why: `pedido ${shortModel(asked)} (≤ regla ${p.model})` }
77 return { model: p.model, why: `forzado a ${p.model}: se pidio ${shortModel(asked)} pero la regla dice ${p.why}` }
78}
79
80export const spawnNote = (p: Pick, explicit?: string, parentModel?: string) => {
81 const r = resolveModel(p, explicit, parentModel)
82 return `squadagents: este subagente corre con ${r.model} (${r.why}). Las reglas mandan; un model explicito solo puede bajar el costo.`
83}
84
85// Precios oficiales USD por millón de tokens (skill claude-api, cache 2026-10-06).
86// cache write = 1.25× input (TTL 5 min). Se elige por el id real que reporta la API.
87type Price = { in: number; out: number; cw: number; cr: number }
88const PRICES: Array<[RegExp, Price]> = [
89 [/opus-5-5|opus/i, { in: 4, out: 20, cw: 5, cr: 0.2 }],
90 [/sonnet-5-5|sonnet-5\b|sonnet/i, { in: 2, out: 10, cw: 2.5, cr: 0.2 }],
91 [/haiku-5-5/i, { in: 0.1, out: 0.5, cw: 0.125, cr: 0.01 }],
92 [/haiku/i, { in: 1, out: 5, cw: 1.25, cr: 0.1 }],
93]
94const OPUS_PRICE: Price = { in: 4, out: 20, cw: 5, cr: 0.2 }
95const priceOf = (model: string): Price => (PRICES.find(([re]) => re.test(model)) || [null, OPUS_PRICE])[1]
96
97export type Usage = { input_tokens: number; output_tokens: number; cache_creation_input_tokens: number; cache_read_input_tokens: number }
98const costWith = (p: Price, u: Usage) =>
99 (u.input_tokens * p.in + u.output_tokens * p.out + u.cache_creation_input_tokens * p.cw + u.cache_read_input_tokens * p.cr) / 1e6
100
101// Costo real del paso y lo que habria costado en opus.
102export const stepCost = (model: string, u: Usage) => ({ cost: costWith(priceOf(model), u), opus: costWith(OPUS_PRICE, u) })
103
104export const fmtUsd = (n: number) => (n >= 1 ? `$${n.toFixed(2)}` : `$${n.toFixed(3)}`)
105
106// Grupo = primera palabra de la descripcion si trae guion o digito ("SMC-P1 1.2 x" -> "SMC-P1"); si no, "otros".
107export const groupOf = (task: string) => {
108 const w = (task || '').trim().split(/\s+/)[0] || ''
109 return /[-\d]/.test(w) ? w : 'otros'
110}
111
112// Barra de avance de texto: n de total, `width` celdas.
113export const bar = (n: number, total: number, width = 10) => {
114 const f = total > 0 ? Math.round((Math.min(n, total) / total) * width) : 0
115 return '█'.repeat(f) + '░'.repeat(width - f)
116}
117
118// Agrupa conservando el orden de primera aparicion.
119export function groupAgents<T extends { task: string }>(list: T[]): Array<[string, T[]]> {
120 const m = new Map<string, T[]>()
121 for (const a of list) {
122 const g = groupOf(a.task)
123 m.set(g, [...(m.get(g) ?? []), a])
124 }
125 return [...m]
126}
127
128// Racha de acciones directas del principal: avisa tras 3 de navegador o 5 de trabajo seguidas.
129export type Streak = { browser: number; work: number; direct: number }
130export const isBrowserTool = (n: string) => /^mcp__(Claude_Browser|claude-in-chrome)__/.test(n)
131export const isWorkTool = (n: string) => /^(Bash|PowerShell|Edit|Write)$/.test(n)
132export function bumpStreak(s: Streak, tool: string): { s: Streak; note?: string } {
133 const b = isBrowserTool(tool), w = isWorkTool(tool)
134 if (!b && !w) return { s }
135 const n = { browser: b ? s.browser + 1 : 0, work: w ? s.work + 1 : 0, direct: s.direct + 1 }
136 const k = b ? n.browser : n.work
137 const note = (b ? k > 3 : k > 5)
138 ? `squadagents: llevas ${k} acciones directas seguidas con el modelo principal; delega este trabajo a un subagente (navegador con guion → haiku; implementar/diagnosticar → sonnet)`
139 : undefined
140 return { s: n, note }
141}
142
143// Modo estricto: herramientas exentas (nunca cuentan ni se bloquean).
144export const isExempt = (n: string) =>
145 /^(Agent|SendMessage|AskUserQuestion|Skill|ToolSearch|TaskStop|ReadNotifications)$/.test(n) || n.startsWith('mcp__ccd_')
146// Solo bloquean las que bumpStreak cuenta (navegador/Bash/Edit/Write); Read/Grep/Glob pasan.
147export const shouldBlock = (tool: string, direct: number, strict: boolean, limit: number) =>
148 strict && !isExempt(tool) && (isBrowserTool(tool) || isWorkTool(tool)) && direct >= limit
149export const strictDeny = (direct: number) =>
150 `squadagents: modo estricto — llevas ${direct} acciones directas. Delega este trabajo a un subagente con la herramienta Agent (sin model). /squadagents estricto off para desactivar.`
151// "estricto on|off|N" -> cambios a aplicar (undefined = args invalidos).
152export function parseStrict(args: string): { strict?: boolean; limit?: number } | undefined {
153 const a = args.trim().split(/\s+/)[1]?.toLowerCase()
154 if (a === 'on') return { strict: true }
155 if (a === 'off') return { strict: false }
156 const n = Number(a)
157 return a && Number.isInteger(n) && n > 0 ? { limit: n } : undefined
158}
159types/index.d.ts 24 lines1export type AgentRow = {
2 id: string
3 name: string
4 model: string
5 task: string
6 why: string
7 tokens: number
8 cacheRead: number
9 cost?: number
10 opusCost?: number
11 startedAt: number
12 endedAt?: number
13 status: 'running' | 'done' | 'failed'
14 log: string[]
15}
16
17export type UiState = { filter: 'todos' | 'running' | 'done' | 'failed'; open: string[] }
18
19declare module 'claude-code' {
20 interface PluginState {
21 'squadagents': { agents: AgentRow[]; ui: UiState; main: { cost: number }; streak: { browser: number; work: number; direct: number } }
22 }
23}
24