SLOPSHOPPER

agent-squad

Side pane listing the agents at work, plus a live browser map of their flow, files touched and crucial files, with a history of past sessions

newpaneguardcommandpromptprocess
v0.6.0no licenseupdated 2026-10-15Lerma4/claude-code-mods/agent-squad
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-squad
│ ┃ La Squadra ✕ › fix the failing auth test and add an audit log call │ ┃ ⚡ La Squadra 🗺 Flusso │ ┃ 0 al lavoro · 1 a riposo · clic su un nome ⏺ Read(src/auth.ts) │ ┃ per il suo flusso ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ flow:main ○ in attesa · 42s ⎿ Added 2 lines, removed 1 line │ ┃ Coordinatore · Parla con te e smista il ⏺ Bash(bun test) │ ┃ lavoro ⎿ 3 pass, 1 fail │ ┃ ↳ Aspetta istruzioni │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /squadra │ ⎿ agent-squad: Pannello della squadra aperto. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · La Squadra
⚡ La Squadra 🗺 Flusso 0 al lavoro · 1 a riposo · clic su un nome per il suo flusso flow:main ○ in attesa · 42s Coordinatore · Parla con te e smista il lavoro ↳ Aspetta istruzioni
README

claude-code-mods

A growing collection of mods for Claude Code.

ModWhat it does
agent-squadSide pane listing the agents at work: nickname, icon, role, status and current activity. /squadra flusso (or a click on an agent) opens a live map in the browser: call graph, timeline, files touched and crucial files. /squadra storico lists past sessions and the files each one changed (Windows only for now)
token-barBar above the prompt showing account usage (5h and 7-day limits), context and cost

The in-app labels are in Italian.

Install

Inside Claude Code:

/plugin marketplace add Lerma4/claude-code-mods
/plugin install agent-squad@claude-code-mods
/plugin install token-bar@claude-code-mods

Then restart Claude Code.

Update

/plugin marketplace update claude-code-mods

Or enable auto-update for this marketplace from the /plugin menu.

Source 3 files
hooks/register.tsx 329 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SquadAgent, SquadStatus } from '../types'
5import { flowAgent, flowCall, flowPrompt, flowStart, flowStatus, openFlow, openHistory } from './flow'
6
7const PANE = 'agent-squad'
8const TITLE = 'La Squadra'
9const MAX = 20
10const MAIN = 'main'
11const agents = atom({ plugin: 'agent-squad', key: 'agents' } as const, [])
12// Ticks once a second while someone is at work, so the running clocks redraw.
13const now = atom({ plugin: 'agent-squad', key: 'now' } as const, 0)
14
15const NAMES = [
16  'Gigi Grep', 'Rita Regex', 'Beppe Bash', 'Tina Token', 'Nando Null',
17  'Pina Pipeline', 'Ugo Undefined', 'Franca Fork', 'Mimmo Merge', 'Lella Lambda',
18  'Tonino Timeout', 'Carla Cache', 'Sandro Stacktrace', 'Wanda Wildcard',
19  'Peppino Ping', 'Ornella Overflow', 'Dario Deploy', 'Silvia Socket',
20  'Rocco Rollback', 'Gina Git',
21]
22
23// A random name not already on screen; repeats only when every name is taken.
24export const pickName = (taken: string[], rand = Math.random): string => {
25  const free = NAMES.filter(n => !taken.includes(n))
26  const pool = free.length ? free : NAMES
27  return pool[Math.floor(rand() * pool.length)]
28}
29
30const ROLES: Record<string, { logo: string; title: string; role: string }> = {
31  main: { logo: '👑', title: 'Coordinatore', role: 'Parla con te e smista il lavoro' },
32  Explore: { logo: '🔍', title: 'Segugio', role: 'Fruga nel codice e trova file e simboli' },
33  Plan: { logo: '🧭', title: 'Architetto', role: "Disegna il piano d'attacco" },
34  'general-purpose': { logo: '🔧', title: 'Tuttofare', role: 'Ricerche e lavori in più passi' },
35  'claude-code-guide': { logo: '📖', title: 'Bibliotecario', role: 'Conosce a memoria la documentazione' },
36  fork: { logo: '🧬', title: 'Clone', role: 'Una copia del capo con lo stesso contesto' },
37  'statusline-setup': { logo: '🎨', title: 'Decoratore', role: 'Sistema la status line' },
38  teammate: { logo: '🤝', title: 'Compagno di squadra', role: 'Lavora in parallelo nel team' },
39  claude: { logo: '🧠', title: 'Jolly', role: 'Agente generico, fa un po\' di tutto' },
40}
41
42// Generic agents: the job, not the type, says what they do.
43const GENERIC = new Set(['general-purpose', 'claude', 'fork'])
44
45// Role guessed from keywords in a custom agent's name or in the task description (English or Italian).
46// First match wins, so the more specific roles come first.
47const KINDS: [RegExp, { logo: string; title: string; role: string }][] = [
48  [/secur|sicurezz|\bauth|login|permess|pentest|vuln/, { logo: '🛡️', title: 'Guardiano', role: 'Permessi, login e vulnerabilità' }],
49  [/debug|\bbug|diagnos|\bfix|corregg|errore/, { logo: '🐛', title: 'Acchiappabug', role: 'Riproduce e risolve i bug' }],
50  [/review|revision|rivedi|audit|critic/, { logo: '🧐', title: 'Revisore', role: 'Rilegge il codice e scova i difetti' }],
51  [/\btest(s|ing)?\b|collaud|\bqa\b|tdd|e2e|regression/, { logo: '🧪', title: 'Collaudatore', role: 'Scrive test e caccia regressioni' }],
52  [/\b(db|sql)\b|database|schema|migra|prisma|tabell|query/, { logo: '🗄️', title: 'Archivista', role: 'Tabelle, query e migrazioni' }],
53  [/devops|deploy|docker|k8s|kube|\bci\b|infra|release|rilasc|pipeline/, { logo: '🚀', title: 'Sistemista', role: 'Build, deploy e infrastruttura' }],
54  [/perf|prestazion|optimi|ottimizz|velocizz|cache/, { logo: '⚡', title: 'Ottimizzatore', role: 'Rende tutto più veloce' }],
55  [/integra|webhook|\bsync|connector/, { logo: '🔌', title: 'Integratore', role: 'Collega sistemi esterni e API di terzi' }],
56  [/\bdat[ai]\b|analy|anali|report|etl|export|import|estrazion/, { logo: '📊', title: 'Analista', role: 'Dati, report ed export' }],
57  [/asset|image|immagin|icon/, { logo: '🖼️', title: 'Grafico', role: 'Prepara immagini e asset' }],
58  [/design|\bux\b|style|stile|theme|\btema|grafic/, { logo: '💅', title: 'Designer', role: 'Cura UX e aspetto grafico' }],
59  [/front|\bui\b|react|angular|\bvue|svelte|css|component|interfacc|schermat|pagin|maschera|\bform\b/, { logo: '🖥️', title: 'Frontendista', role: 'Schermate, form e componenti' }],
60  [/\bback|\bapi\b|server|service|servizi|endpoint/, { logo: '⚙️', title: 'Backendista', role: 'API e logica di business' }],
61  [/i18n|transl|tradu|locali/, { logo: '🌍', title: 'Traduttore', role: 'Testi e traduzioni' }],
62  [/\bdoc|writer|readme|changelog/, { logo: '📝', title: 'Scrivano', role: 'Documentazione e README' }],
63  [/refactor|rifattor|simplif|semplific|clean|pulizi/, { logo: '🧹', title: 'Spazzino', role: 'Refactoring e pulizia' }],
64  [/architect|architett|\bplan|\bpiano/, { logo: '🧭', title: 'Architetto', role: "Disegna il piano d'attacco" }],
65  [/explor|esplor|search|research|find|\bcerca|\btrova/, { logo: '🔍', title: 'Segugio', role: 'Fruga nel codice e trova file e simboli' }],
66]
67
68const pretty = (s: string) => s.replace(/[-_]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
69const guess = (text: string) => KINDS.find(([re]) => re.test(text.toLowerCase()))?.[1]
70
71export const roleOf = (type: string, description = '') => {
72  if (ROLES[type] && !GENERIC.has(type)) return ROLES[type]
73  // Plugin agents are named 'plugin:agent'; the plugin name only says where it came from.
74  const [plugin, agent] = type.includes(':') ? type.split(/:(.*)/) : ['', type]
75  const kind = (!GENERIC.has(type) && guess(agent)) || guess(description)
76  if (kind) return kind
77  if (ROLES[type]) return ROLES[type]
78  return plugin
79    ? { logo: '🧩', title: pretty(agent), role: `Dal plugin ${plugin}` }
80    : { logo: '🤖', title: 'Agente', role: type }
81}
82
83const STATUS: Record<SquadStatus, { icon: string; label: string; color: string }> = {
84  running: { icon: '●', label: 'al lavoro', color: 'claude' },
85  idle: { icon: '○', label: 'in attesa', color: 'subtle' },
86  completed: { icon: '✔', label: 'finito', color: 'success' },
87  failed: { icon: '✖', label: 'errore', color: 'error' },
88  killed: { icon: '■', label: 'fermato', color: 'inactive' },
89}
90
91// The tool argument that best says what a call is doing, first match wins.
92const HINTS = ['description', 'command', 'file_path', 'pattern', 'url', 'query', 'path', 'prompt']
93
94export const describe = (e: { tool: string }): string => {
95  const args = e as unknown as Record<string, unknown>
96  const hint = HINTS.map(k => args[k]).find((v): v is string => typeof v === 'string' && v.trim() !== '')
97  return hint ? `${e.tool}: ${hint.replace(/\s+/g, ' ').trim()}` : e.tool
98}
99
100// 'claude-haiku-4-5-20251001' -> 'haiku 4.5'; an alias ('haiku') stays as it is.
101export const shortModel = (model: string): string =>
102  model
103    .replace(/^claude-/, '')
104    .replace(/\[.*\]$/, '')
105    .replace(/-\d{8}$/, '')
106    .replace(/-(\d+)-(\d+)$/, ' $1.$2')
107    .replace(/-(\d+)$/, ' $1')
108
109// 42_000 -> '42s', 125_000 -> '2m 05s', 3_720_000 -> '1h 02m'.
110export const elapsed = (ms: number): string => {
111  const s = Math.max(0, Math.floor(ms / 1000))
112  const pad = (n: number) => String(n).padStart(2, '0')
113  if (s < 60) return `${s}s`
114  if (s < 3600) return `${Math.floor(s / 60)}m ${pad(s % 60)}s`
115  return `${Math.floor(s / 3600)}h ${pad(Math.floor(s / 60) % 60)}m`
116}
117
118// Starts the clock of a row that was not already running.
119const start = async ($: EngineInterface, id: string, patch: Partial<SquadAgent>) => {
120  const t = await $.clock.now()
121  await touch($, id, a => ({ ...patch, status: 'running', since: a.status === 'running' && a.since ? a.since : t }))
122}
123
124const fresh = (id: string, list: SquadAgent[]): SquadAgent =>
125  id === MAIN
126    ? { id, name: 'Il Capo', type: 'main', description: '', status: 'idle', activity: '' }
127    : {
128        id,
129        name: pickName(list.map(a => a.name)),
130        type: 'agent',
131        description: '',
132        status: 'running',
133        activity: '',
134      }
135
136// Patch the row with this id, adding it first when it is new; trims the oldest finished rows.
137const touch = ($: EngineInterface, id: string, patch: (a: SquadAgent) => Partial<SquadAgent>) =>
138  update($, agents, list => {
139    const known = list.some(a => a.id === id)
140    const all = known ? list : [...list, fresh(id, list)]
141    const out = all.map(a => (a.id === id ? { ...a, ...patch(a) } : a))
142    while (out.length > MAX) {
143      const old = out.findIndex(a => a.id !== MAIN && a.status !== 'running')
144      if (old < 0) break
145      out.splice(old, 1)
146    }
147    return out
148  })
149
150const ended = (a: SquadAgent, reason: string): Partial<SquadAgent> => {
151  if (a.id === MAIN || a.type === 'teammate') return { status: 'idle', activity: 'Aspetta istruzioni' }
152  if (reason === 'answer') return { status: 'completed', activity: 'Missione compiuta' }
153  if (reason === 'aborted') return { status: 'killed', activity: 'Interrotto' }
154  return { status: 'failed', activity: 'Qualcosa è andato storto' }
155}
156
157// New random names for every agent but the boss, or only for the one called `who`.
158export const reroll = (list: SquadAgent[], who = '', rand = Math.random): SquadAgent[] => {
159  const out = list.map(a => ({ ...a }))
160  for (const a of out) {
161    if (a.id !== MAIN && (!who || a.name.toLowerCase() === who.toLowerCase())) a.name = pickName(out.map(b => b.name), rand)
162  }
163  return out
164}
165
166const order = (a: SquadAgent) => (a.id === MAIN ? 0 : a.status === 'running' ? 1 : a.status === 'idle' ? 2 : 3)
167
168export const register: Register = (on, options) => {
169  // How many past sessions keep their full flow; the rest keep only their history row.
170  const keep = Math.max(1, Math.round(Number(options.keepSessions) || 20))
171
172  on('session.start', async ($, e, next) => {
173    await $.command.register({ name: 'squadra', description: 'Mostra il pannello con gli agenti al lavoro', argumentHint: '[flusso [nome] | storico | pulisci | tombola [nome]]' })
174    void $.ui.open({ id: PANE, title: TITLE, columns: 46 })
175    await flowStart($, keep)
176    $.clock.every(1000, async () => {
177      if ((await read($, agents)).some(a => a.status === 'running')) {
178        const t = await $.clock.now()
179        await update($, now, () => t)
180      }
181    })
182    return next(e)
183  })
184
185  on('command.run', { command: 'squadra' }, async ($, e) => {
186    const [sub = '', ...rest] = e.args.trim().split(/\s+/)
187    if (sub === 'tombola') {
188      const who = rest.join(' ')
189      const target = (await read($, agents)).find(a => a.id !== MAIN && a.name.toLowerCase() === who.toLowerCase())
190      if (who && !target) return { text: `Nessun agente si chiama ${who} (il capo non partecipa alla tombola).` }
191      const out = await update($, agents, l => reroll(l, who))
192      if (!target) return { text: 'Tombola! Nuovi nomi estratti per tutta la squadra, capo escluso.' }
193      return { text: `Ambo! ${target.name} ora si chiama ${out.find(a => a.id === target.id)?.name}.` }
194    }
195    if (sub === 'flusso') {
196      const who = rest.join(' ')
197      const target = who ? (await read($, agents)).find(a => a.name.toLowerCase() === who.toLowerCase()) : undefined
198      if (who && !target) return { text: `Nessun agente si chiama ${who}.` }
199      const missed = await openFlow($, target?.id)
200      if (missed) return { text: `Non riesco ad aprire il browser, apri a mano: ${missed}` }
201      return { text: target ? `Flusso di ${target.name} aperto nel browser.` : 'Flusso della sessione aperto nel browser.' }
202    }
203    if (sub === 'storico') {
204      const missed = await openHistory($)
205      return { text: missed ? `Non riesco ad aprire il browser, apri a mano: ${missed}` : 'Storico delle sessioni aperto nel browser.' }
206    }
207    if (sub === 'pulisci') {
208      await update($, agents, list => list.filter(a => a.id === MAIN || a.status === 'running'))
209      return { text: 'Squadra ripulita: restano solo gli agenti al lavoro.' }
210    }
211    await $.ui.open({ id: PANE, title: TITLE, columns: 46 })
212    return { text: 'Pannello della squadra aperto.' }
213  })
214
215  on('agent.spawn', async ($, e, next) => {
216    const started = await next(e)
217    if (started.agentId) {
218      await flowAgent($, {
219        id: started.agentId,
220        parent: e.parentAgentId ?? MAIN,
221        call: e.tool_use_id,
222        type: e.subagentType,
223        description: e.description,
224        model: started.model,
225      })
226      await start($, started.agentId, {
227        type: e.subagentType,
228        description: e.description,
229        model: started.model,
230        activity: 'Si scalda i muscoli…',
231      })
232    }
233    return started
234  })
235
236  on('tool.call', async ($, e, next) => {
237    const id = e.agentId ?? MAIN
238    const list = await read($, agents)
239    if (id !== MAIN && !list.some(a => a.id === id)) {
240      // Not spawned while we watched (a reload, a teammate): adopt it only if the engine lists it.
241      const info = (await $.agent.list()).find(a => a.id === id)
242      if (!info) return next(e)
243      await touch($, id, () => ({ type: info.type, description: info.description }))
244    }
245    await start($, id, { activity: describe(e) })
246    const finish = await flowCall($, e as unknown as Record<string, unknown>)
247    const ran = await next(e)
248    await finish(ran)
249    return ran
250  })
251
252  on('prompt.submit', async ($, e, next) => {
253    if (!e.isMeta && !e.command) await flowPrompt($, e.text)
254    return next(e)
255  })
256
257  // Every model request names the model it goes to: the live truth for the main loop and each subagent.
258  on('turn.step', async function* ($, e, next) {
259    const id = e.agentId ?? MAIN
260    const row = (await read($, agents)).find(a => a.id === id)
261    const effort = e.effort === undefined ? undefined : String(e.effort)
262    if ((row || id === MAIN) && (row?.model !== e.model || row?.effort !== effort)) {
263      await touch($, id, () => ({ model: e.model, effort }))
264    }
265    return yield* next(e)
266  })
267
268  on('turn.start', async ($, e, next) => {
269    await start($, MAIN, { activity: 'Ci sta pensando…' })
270    await flowStatus($, MAIN, 'running', false)
271    return next(e)
272  })
273
274  on('turn.complete', async ($, e, next) => {
275    const id = e.agentId ?? MAIN
276    const list = await read($, agents)
277    if (id === MAIN || list.some(a => a.id === id)) {
278      const t = await $.clock.now()
279      await touch($, id, a => ({ ...ended(a, e.reason), since: undefined, lastMs: e.durationMs || (a.since ? t - a.since : a.lastMs) }))
280    }
281    await flowStatus($, id, id === MAIN ? 'idle' : e.reason === 'answer' ? 'completed' : e.reason === 'aborted' ? 'killed' : 'failed', true)
282    return next(e)
283  })
284
285  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
286    const { Box, Text, Button } = $.ui.resolve(e)
287    const list = [...(await read($, agents))].sort((a, b) => order(a) - order(b))
288    const busy = list.filter(a => a.status === 'running').length
289    await read($, now) // subscribes the pane to the ticks
290    const t = await $.clock.now()
291    // Running: how long so far. Otherwise: how long the last task took.
292    const timer = (a: SquadAgent) =>
293      a.status === 'running' ? (a.since ? ` · ${elapsed(t - a.since)}` : '') : a.lastMs !== undefined ? ` · ${elapsed(a.lastMs)}` : ''
294
295    return (
296      <Box flexDirection="column">
297        <Box justifyContent="space-between" flexWrap="wrap" columnGap={1}>
298          <Text bold color="claude">⚡ La Squadra</Text>
299          <Button key="flow" plain dimColor onPress={() => void openFlow($)}>🗺 Flusso</Button>
300        </Box>
301        <Text dimColor wrap="wrap">{busy} al lavoro · {list.length - busy} a riposo · clic su un nome per il suo flusso</Text>
302        {list.length === 0 && <Text dimColor>Nessun agente in campo.</Text>}
303        {list.map(a => {
304          const r = roleOf(a.type, a.description)
305          const s = STATUS[a.status]
306          return (
307            <Box flexDirection="column" marginTop={1}>
308              {/* Wraps instead of truncating: in a narrow pane the status drops below the name. */}
309              <Box flexWrap="wrap" justifyContent="space-between" columnGap={1}>
310                <Box flexWrap="wrap" flexShrink={1}>
311                  {/* A click opens this agent's flow in the browser. */}
312                  <Button key={`flow:${a.id}`} plain onPress={() => void openFlow($, a.id)}>
313                    <Text bold wrap="wrap">{r.logo} {a.name}</Text>
314                  </Button>
315                  {a.model && <Text color="suggestion" wrap="wrap"> · {shortModel(a.model)}</Text>}
316                  {a.effort && <Text color="warning" wrap="wrap"> · {a.effort}</Text>}
317                </Box>
318                <Text color={s.color} wrap="wrap">{s.icon} {s.label}{timer(a)}</Text>
319              </Box>
320              <Text dimColor italic wrap="wrap">{r.title} · {a.description || r.role}</Text>
321              <Text color={a.status === 'running' ? 'text' : 'subtle'} wrap="wrap">↳ {a.activity || '—'}</Text>
322            </Box>
323          )
324        })}
325      </Box>
326    )
327  })
328}
329
hooks/flow.ts 257 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import type { FileStat, Flow, FlowAgent, FlowEvent, FlowKind, FlowSummary, FlowTouch } from '../types'
5
6// The flow view: every tool call, spawn and prompt, dumped to data.js beside a static page
7// (viewer/index.html) that re-reads it every second in the browser.
8
9const MAIN = 'main'
10const MAX_EVENTS = 3000
11// File touches are small and kept apart, so the file list outlives the oldest calls.
12const MAX_TOUCHES = 20000
13const CLIP = 1500
14// The history lives in $.store (4 MiB in all): a few KB per session.
15const HISTORY = 'storico'
16const MAX_HISTORY = 1500
17const MAX_HISTORY_FILES = 50
18const HISTORY_EVERY_MS = 30_000
19const flow = atom({ plugin: 'agent-squad', key: 'flow' } as const, { agents: [], events: [], prompts: [], touches: [] } as Flow)
20
21const VERIFY = /\b(test|tests|pytest|vitest|jest|tsc|lint|eslint|build|mvn|gradle|cargo|go test|dotnet|npm run|pnpm|yarn|make)\b/i
22const SEARCH = new Set(['Grep', 'Glob', 'LS', 'WebSearch', 'WebFetch', 'ToolSearch', 'LSP'])
23
24export const kindOf = (tool: string, command = ''): FlowKind => {
25  if (tool === 'Read' || tool === 'NotebookRead') return 'read'
26  if (SEARCH.has(tool)) return 'search'
27  if (tool === 'Edit' || tool === 'MultiEdit' || tool === 'NotebookEdit') return 'edit'
28  if (tool === 'Write') return 'write'
29  if (tool === 'Bash' || tool === 'PowerShell') return VERIFY.test(command) ? 'verify' : 'shell'
30  if (tool === 'Agent' || tool === 'Task') return 'agent'
31  return 'other'
32}
33
34// Same file, same key: forward slashes, relative to the session directory when under it.
35export const relPath = (path: string, cwd: string): string => {
36  const p = path.replace(/\\/g, '/')
37  const c = cwd.replace(/\\/g, '/').replace(/\/$/, '')
38  return p.toLowerCase().startsWith(c.toLowerCase() + '/') ? p.slice(c.length + 1) : p
39}
40
41const HINTS = ['description', 'command', 'file_path', 'notebook_path', 'pattern', 'url', 'query', 'path', 'prompt']
42const clip = (s: unknown, n = CLIP) => (typeof s === 'string' ? (s.length > n ? s.slice(0, n) + '…' : s) : undefined)
43
44export const toEvent = (e: Record<string, unknown>, cwd: string, n: number, t: number): FlowEvent => {
45  const tool = String(e.tool)
46  const kind = kindOf(tool, typeof e.command === 'string' ? e.command : '')
47  const hint = HINTS.map(k => e[k]).find((v): v is string => typeof v === 'string' && v.trim() !== '') ?? ''
48  const raw = (e.file_path ?? e.notebook_path) as string | undefined
49  const file = raw && ['read', 'edit', 'write'].includes(kind) ? relPath(raw, cwd) : undefined
50  const ev: FlowEvent = { n, t, agent: (e.agentId as string) ?? MAIN, tool, kind, hint: clip(hint.replace(/\s+/g, ' ').trim(), 200)!, file }
51  if (kind === 'agent') ev.call = e.tool_use_id as string
52  if (tool === 'Edit') Object.assign(ev, { old: clip(e.old_string), new: clip(e.new_string) })
53  if (tool === 'Write') ev.new = clip(e.content)
54  if (kind === 'verify' || kind === 'shell') ev.old = clip(e.command)
55  return ev
56}
57
58// Attention per file and why it matters. ponytail: heuristic weights, tune them by eye.
59export const fileStats = (events: readonly FlowTouch[]): FileStat[] => {
60  const by = new Map<string, FileStat>()
61  const get = (path: string) => by.get(path) ?? by.set(path, { path, reads: 0, edits: 0, writes: 0, agents: [], score: 0, why: [] }).get(path)!
62  const lastFail = new Map<string, number>()
63  const recentReads = new Map<string, string[]>()
64  for (const ev of events) {
65    if (ev.kind === 'verify') {
66      if (ev.ok === false) lastFail.set(ev.agent, ev.n)
67      else lastFail.delete(ev.agent)
68    }
69    if (!ev.file) continue
70    const f = get(ev.file)
71    if (!f.agents.includes(ev.agent)) f.agents.push(ev.agent)
72    if (ev.kind === 'read') {
73      f.reads++
74      const r = (recentReads.get(ev.agent) ?? []).filter(p => p !== ev.file).slice(-4)
75      recentReads.set(ev.agent, [...r, ev.file])
76      continue
77    }
78    if (ev.kind === 'edit') f.edits++
79    else f.writes++
80    // The files read just before a change are the context the change came from.
81    for (const p of recentReads.get(ev.agent) ?? []) {
82      if (p === ev.file) continue
83      const ctx = get(p)
84      ctx.score += 2
85      if (!ctx.why.includes('contesto di una modifica')) ctx.why.push('contesto di una modifica')
86    }
87    recentReads.set(ev.agent, [])
88    if (lastFail.has(ev.agent)) {
89      f.score += 3
90      if (!f.why.includes('corretto dopo un test fallito')) f.why.push('corretto dopo un test fallito')
91    }
92  }
93  for (const f of by.values()) {
94    f.score += 5 * (f.edits + f.writes) + f.reads + 2 * (f.agents.length - 1)
95    if (f.writes && !f.edits) f.why.unshift('creato')
96    else if (f.edits) f.why.unshift(`modificato ${f.edits}×`)
97    if (f.reads > 2) f.why.push(`riletto ${f.reads}×`)
98    if (f.agents.length > 1) f.why.push(`${f.agents.length} agenti`)
99  }
100  return [...by.values()].sort((a, b) => b.score - a.score)
101}
102
103// Pure: the history row of one session, modified files only, the most crucial first.
104export const summarize = (id: string, project: string, f: Flow, updated: number): FlowSummary => ({
105  id,
106  project,
107  start: f.agents.find(a => a.id === MAIN)?.start ?? updated,
108  updated,
109  agents: f.agents.filter(a => a.id !== MAIN).length,
110  calls: f.events.at(-1)?.n ?? 0,
111  prompt: f.prompts[0]?.text.slice(0, 120),
112  files: fileStats(f.touches)
113    .filter(s => s.edits + s.writes > 0)
114    .slice(0, MAX_HISTORY_FILES)
115    .map(s => ({ path: s.path, edits: s.edits, writes: s.writes, score: s.score })),
116})
117
118// Pure: the session folders to delete, all but the `keep` most recent (the current one always kept).
119export const toPrune = (dirs: readonly string[], history: readonly FlowSummary[], current: string, keep: number): string[] => {
120  const when = new Map(history.map(h => [h.id, h.updated]))
121  return dirs
122    .filter(d => d !== current)
123    .sort((a, b) => (when.get(b) ?? 0) - (when.get(a) ?? 0))
124    .slice(Math.max(0, keep - 1))
125}
126
127// Module state: a reload starts these over, the flow itself lives in $.state.
128let root = ''
129let out = ''
130let sessionId = ''
131let dirty = true
132let seq = 0
133let savedAt = 0
134
135function patch($: EngineInterface, id: string, p: (a: FlowAgent) => Partial<FlowAgent>) {
136  return update($, flow, f => ({ ...f, agents: f.agents.map(a => (a.id === id ? { ...a, ...p(a) } : a)) }))
137}
138
139async function history($: EngineInterface): Promise<FlowSummary[]> {
140  return ((await $.store.get(HISTORY)) as FlowSummary[] | undefined) ?? []
141}
142
143// Folders under sessioni/ that still hold a full flow.
144async function detailed($: EngineInterface): Promise<string[]> {
145  if (!(await $.fs.exists(`${root}/sessioni`))) return []
146  return (await $.fs.list(`${root}/sessioni`)).filter(e => e.kind === 'dir' && /^[\w-]+$/.test(e.name)).map(e => e.name)
147}
148
149// Writes this session's row into the history and the history page's data.
150async function saveHistory($: EngineInterface, f: Flow, now: number) {
151  savedAt = now
152  const row = summarize(sessionId, await $.session.root(), f, now)
153  const rows = [row, ...(await history($)).filter(h => h.id !== sessionId)].slice(0, MAX_HISTORY)
154  await $.store.set(HISTORY, rows)
155  const detail = await detailed($)
156  await $.fs.write(`${root}/storico.js`, `window.__STORICO(${JSON.stringify(rows.map(r => ({ ...r, detail: detail.includes(r.id) })))})`)
157}
158
159// Rewrites data.js when something changed, and the history row at most every 30 s.
160async function dump($: EngineInterface, force = false) {
161  if (!out || (!dirty && !force)) return
162  dirty = false
163  const f = await read($, flow)
164  const now = await $.clock.now()
165  const data = { ...f, touches: undefined, files: fileStats(f.touches), cwd: await $.session.cwd(), updated: now }
166  await $.fs.write(`${out}/data.js`, `window.__FLOW(${JSON.stringify(data)})`)
167  if (force || now - savedAt > HISTORY_EVERY_MS) await saveHistory($, f, now)
168}
169
170async function launch($: EngineInterface, page: string): Promise<string> {
171  const path = page.replace(/\//g, '\\')
172  const ran = await $.process.run(['cmd', '/c', 'start', '', path])
173  return ran.exitCode === 0 ? '' : path
174}
175
176// Opens the page in the default browser, on the whole session or on one agent and its subagents.
177export async function openFlow($: EngineInterface, agentId?: string): Promise<string> {
178  dirty = true
179  await dump($, true)
180  // `start` drops a #fragment on a file path, so a one-line page carries it instead.
181  if (!agentId) return launch($, `${out}/index.html`)
182  await $.fs.write(`${out}/agente.html`, `<script>location.replace('index.html#agent=' + ${JSON.stringify(encodeURIComponent(agentId))})</script>`)
183  return launch($, `${out}/agente.html`)
184}
185
186// Opens the list of past sessions.
187export async function openHistory($: EngineInterface): Promise<string> {
188  await dump($, true)
189  return launch($, `${root}/index.html`)
190}
191
192// Called from the squad's own hooks: the engine takes one unfiltered hook per event per plugin.
193
194export async function flowStart($: EngineInterface, keep: number) {
195  // ponytail: Windows only (%USERPROFILE%, `start`); add open/xdg-open when someone runs it elsewhere.
196  const home = (await $.process.run(['cmd', '/c', 'echo %USERPROFILE%'])).stdout.trim().replace(/\\/g, '/')
197  root = `${home}/.claude/agent-flow`
198  sessionId = await $.session.id()
199  out = `${root}/sessioni/${sessionId}`
200  await $.fs.write(`${out}/index.html`, await $.fs.read(`${$.plugin.root}/viewer/index.html`))
201  await $.fs.write(`${root}/index.html`, await $.fs.read(`${$.plugin.root}/viewer/storico.html`))
202  for (const old of toPrune(await detailed($), await history($), sessionId, keep)) {
203    await $.process.run(['cmd', '/c', 'rmdir', '/s', '/q', `${root}/sessioni/${old}`.replace(/\//g, '\\')])
204  }
205  // A flow kept from 0.5.0, before touches existed.
206  if (!(await read($, flow)).touches) {
207    await update($, flow, x => ({ ...x, touches: x.events.filter(e => e.file || e.kind === 'verify').map(e => ({ n: e.n, agent: e.agent, kind: e.kind, file: e.file, ok: e.ok })) }))
208  }
209  const f = await read($, flow)
210  seq = f.events.at(-1)?.n ?? 0
211  if (!f.agents.some(a => a.id === MAIN)) {
212    const t = await $.clock.now()
213    await update($, flow, x => ({ ...x, agents: [{ id: MAIN, type: 'main', description: 'Sessione principale', start: t, status: 'idle' as const }, ...x.agents] }))
214  }
215  $.clock.every(1000, () => dump($))
216}
217
218export async function flowPrompt($: EngineInterface, text: string) {
219  const t = await $.clock.now()
220  await update($, flow, f => ({ ...f, prompts: [...f.prompts, { t, text: text.slice(0, 300) }] }))
221  dirty = true
222}
223
224export async function flowAgent($: EngineInterface, a: Omit<FlowAgent, 'start' | 'status'>) {
225  const t = await $.clock.now()
226  await update($, flow, f => ({ ...f, agents: [...f.agents.filter(x => x.id !== a.id), { ...a, start: t, status: 'running' as const }] }))
227  dirty = true
228}
229
230export async function flowStatus($: EngineInterface, id: string, status: FlowAgent['status'], isEnd: boolean) {
231  const t = await $.clock.now()
232  await patch($, id, () => (isEnd ? { status, end: t } : { status }))
233  dirty = true
234}
235
236// Records the call before it runs; the returned function records how it ended.
237export async function flowCall($: EngineInterface, e: Record<string, unknown>) {
238  const t = await $.clock.now()
239  const ev = toEvent(e, await $.session.cwd(), ++seq, t)
240  const touch: FlowTouch | undefined = ev.file || ev.kind === 'verify' ? { n: ev.n, agent: ev.agent, kind: ev.kind, file: ev.file } : undefined
241  await update($, flow, f => ({
242    ...f,
243    events: [...f.events, ev].slice(-MAX_EVENTS),
244    touches: touch ? [...f.touches, touch].slice(-MAX_TOUCHES) : f.touches,
245  }))
246  dirty = true
247  return async (ran: { deny?: string; isError?: boolean; text?: string }) => {
248    const done = { ms: (await $.clock.now()) - t, ok: ran.deny === undefined && !ran.isError, out: clip(ran.deny ?? ran.text, 800) }
249    await update($, flow, f => ({
250      ...f,
251      events: f.events.map(x => (x.n === ev.n ? { ...x, ...done } : x)),
252      touches: touch ? f.touches.map(x => (x.n === ev.n ? { ...x, ok: done.ok } : x)) : f.touches,
253    }))
254    dirty = true
255  }
256}
257
types/index.d.ts 84 lines
1export type SquadStatus = 'running' | 'idle' | 'completed' | 'failed' | 'killed'
2
3export type SquadAgent = {
4  id: string
5  name: string
6  type: string
7  description: string
8  status: SquadStatus
9  activity: string
10  model?: string
11  effort?: string
12  // When the current task started (ms since epoch), set while running.
13  since?: number
14  // How long the last finished task took, in ms.
15  lastMs?: number
16}
17
18export type FlowKind = 'read' | 'search' | 'edit' | 'write' | 'verify' | 'shell' | 'agent' | 'other'
19
20// One tool call as the flow view shows it.
21export type FlowEvent = {
22  n: number
23  t: number
24  ms?: number
25  agent: string
26  tool: string
27  kind: FlowKind
28  hint: string
29  file?: string
30  ok?: boolean
31  // tool_use_id of an Agent call: links it to the subagent it spawned.
32  call?: string
33  old?: string
34  new?: string
35  out?: string
36}
37
38export type FlowAgent = {
39  id: string
40  parent?: string
41  call?: string
42  type: string
43  description: string
44  model?: string
45  start: number
46  end?: number
47  status: SquadStatus
48}
49
50export type FlowPrompt = { t: number; text: string }
51
52// What file scores need from a call, kept longer than the calls themselves.
53export type FlowTouch = Pick<FlowEvent, 'n' | 'agent' | 'kind' | 'file' | 'ok'>
54
55export type Flow = { agents: FlowAgent[]; events: FlowEvent[]; prompts: FlowPrompt[]; touches: FlowTouch[] }
56
57// One past session in the history: small on purpose, it is kept for good.
58export type FlowSummary = {
59  id: string
60  project: string
61  start: number
62  updated: number
63  agents: number
64  calls: number
65  prompt?: string
66  files: { path: string; edits: number; writes: number; score: number }[]
67}
68
69export type FileStat = {
70  path: string
71  reads: number
72  edits: number
73  writes: number
74  agents: string[]
75  score: number
76  why: string[]
77}
78
79declare module 'claude-code' {
80  interface PluginState {
81    'agent-squad': { agents: SquadAgent[]; now: number; flow: Flow }
82  }
83}
84