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

A growing collection of mods for Claude Code.
| Mod | What it does |
|---|---|
| agent-squad | Side 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-bar | Bar above the prompt showing account usage (5h and 7-day limits), context and cost |
The in-app labels are in Italian.
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.
/plugin marketplace update claude-code-mods
Or enable auto-update for this marketplace from the /plugin menu.
hooks/register.tsx 329 lines1import { 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}
329hooks/flow.ts 257 lines1import { 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}
257types/index.d.ts 84 lines1export 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