SLOPSHOPPER

squadagents

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

newpaneguardcommandstatusagents
v0.3.0MITupdated 2026-10-20LordSantox/squadagents
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · squadagents
│ ┃ Squad ✕ › fix the failing auth test and add an audit log call │ ┃ principal: $0.000 · acciones directas: 6 · │ ┃ estricto ≤6 ⏺ Read(src/auth.ts) │ ┃ 0 agentes · $0.000 · ahorro $0.000 vs opus ⎿ Read 6 lines │ ┃ [ Todos ][ Corriendo ][ Terminados ][ Fallad ⏺ Update(src/auth.ts) │ ┃ Sin subagentes todavia. Se listan al ⎿ Added 2 lines, removed 1 line │ ┃ lanzarse. ⏺ Bash(cat .env) │ ⎿ Denied by squadagents: squadagents: modo estricto — lleva │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /squadagents │ ⎿ squadagents: Panel squadagents abierto. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Squad
principal: $0.000 · acciones directas: 6 · estricto ≤6 0 agentes · $0.000 · ahorro $0.000 vs opus [ Todos ][ Corriendo ][ Terminados ][ Fallados ][ Limpiar te Sin subagentes todavia. Se listan al lanzarse.
README

squadagents

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).

Install

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

Install by asking Claude

Paste this into Claude Code:

Install the mod from https://github.com/LordSantox/squadagents following the instructions in its README.

What it does

  • Model routing: when you launch a subagent without 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.
  • An explicit 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.
  • Streak warning: if the main agent takes many direct actions in a row (browser >3, Bash/Edit/Write >5), it adds a note suggesting delegation.
  • Strict mode (on by default): after 6 consecutive direct actions (browser/Bash/Edit/Write) by the main agent, the next one is denied until it delegates with 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.

Uninstall

/plugin uninstall squadagents

License

MIT

Source 4 files
hooks/register.tsx 307 lines
1import 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}
307
hooks/svg.ts 78 lines
1import type { AgentRow } from '../types'
2import { fmtDur, fmtTok, fmtUsd } from './router'
3
4const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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}
78
hooks/router.ts 159 lines
1// 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}
159
types/index.d.ts 24 lines
1export 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