Subagents at a glance: tokens per agent, a toast when one stalls, and a summary naming the heaviest agent when they finish

Subagents at a glance: how many tokens each one used, which one stalled, and which one used the most once they all finish.
idle) and agents held on their own background work (waiting) never count as stalled.Used ~6% of the 5h window). The pane keeps it as "Last run".◇ 2 agents · 365k · ⚠ 1 stalled. It clears when they finish./watch opens the pane. A running agent turns a spinner (◐◓◑◒) while the pane refreshes. Finished agents fold into one line, ✓ 3 finished ▸, which opens them, with a clear finished button next to it. Demo agents carry a demo badge and their own clear demo button. The footer has the verbs and a close button. The command runs mid-turn.investigate button that puts a request about it in the prompt. Nothing stops the agent: Claude Code gives mods no way to.⇅ by start switches the order to by tokens and back. A long list scrolls./watch open the pane
/watch demo add three fake agents, one of them stalled
/watch clear drop finished and demo agents
/watch clear done drop finished agents only
/watch clear demo drop demo agents only
/watch help list the commands
A cleared agent stays out of the pane even while Claude Code still lists it.
Set them in /config, or under pluginConfigs in settings.json.
| Option | Default | What it does |
|---|---|---|
stallMinutes | 5 | Minutes of no model request and no tool call before a running agent counts as stalled |
language | auto | auto, pt-BR or en. auto follows the system's LANG: Portuguese for pt_*, English otherwise |
icons | auto | emoji draws the stall warning as ⚠, symbol as !. auto uses symbol in a JetBrains IDE's terminal and emoji elsewhere |
/plugin install agent-watch --marketplace ice-lfernandes/claude-code-mods
Or for one session: claude --plugin-dir ./agent-watch
| Mod | Network | Runs processes | Files | Calls a model | Sends data anywhere |
|---|---|---|---|---|---|
| agent-watch | No | No | No | No | No |
It reads agent state through $.agent.list and the agent.spawn, turn.step, tool.call and turn.complete events. Its agent.spawn and tool.call hooks only observe: if a hook fails, the spawn or the call goes on. It keeps its values in the session's plugin state and nothing across sessions.
The turn.step hook passes every model response through unchanged to read its usage. That adds a small cost to each request, main conversation included.
hooks/register.tsx 492 lines1// agent-watch: subagents at a glance.
2//
3// tokens per agent, from every model request's usage (`turn.step`), so the count moves while
4// the agent runs. Tokens a loop spends before any listing names it wait as orphans and
5// join the agent once it appears; the engine's own forks (compaction, memory) stay
6// orphans and show as "other".
7// stalls a running agent with no model request and no tool call for `stallMinutes` gets one
8// toast, saying whether it is thinking, inside a tool, or quiet. Activity re-arms it.
9// summary when the last active agent ends, a toast with the wave's agents, tokens, wall time,
10// the heaviest agent and the share of the 5-hour window the wave used; the pane keeps
11// it as "last run".
12// /watch opens the pane: the agent tree with type, model, tokens, tool calls and what each
13// is doing, a bar of each agent's share of the tokens, and a sort by start or by
14// tokens. A name opens its row with its last tool calls; a stalled agent has an
15// `investigate` button that asks about it in the prompt. The list scrolls.
16// Finished agents fold into one line that opens them. /watch clear drops
17// finished and demo agents (clear done, clear demo: one kind); /watch demo seeds
18// three fake ones.
19//
20// Reads nothing from disk, runs no process, calls no model.
21
22import { atom, read, update } from 'claude-code'
23import type { EngineInterface, Register } from 'claude-code'
24
25import type { Agent, Run, Tokens } from '../types'
26import {
27 add,
28 addStep,
29 adopt,
30 clearOut,
31 finish,
32 fiveHourOf,
33 fromUsage,
34 glyphOf,
35 isActive,
36 isFailed,
37 nameOf,
38 quietFor,
39 reconcile,
40 runText,
41 shares,
42 stalls,
43 stallText,
44 summarize,
45 toolEnd,
46 toolStart,
47 total,
48 touch,
49 tree,
50 windowUsed,
51 ZERO,
52} from './watch'
53import type { SortBy } from './watch'
54import { PHRASES, phraseOf } from './phrases'
55import type { IconStyle, Lang, Verb } from './ui'
56import { clip, elapsed, fillArgs, glyph, langOf, linesOf, shortModel, styleOf, tokens, verbRow, windowOf } from './ui'
57import { COMMAND, WORDS } from './words'
58
59const PANE = 'agent-watch'
60const POLL_MS = 5000
61const DEMO = 'demo-'
62
63const agents = atom({ plugin: 'agent-watch', key: 'agents' } as const, [] as Agent[])
64const orphans = atom({ plugin: 'agent-watch', key: 'orphans' } as const, {} as Record<string, Tokens>)
65const lead = atom({ plugin: 'agent-watch', key: 'lead' } as const, ZERO)
66const live = atom({ plugin: 'agent-watch', key: 'live' } as const, 0)
67const standDownAt = atom({ plugin: 'agent-watch', key: 'standDownAt' } as const, 0)
68const lastRun = atom({ plugin: 'agent-watch', key: 'lastRun' } as const, null as Run | null)
69const tick = atom({ plugin: 'agent-watch', key: 'tick' } as const, 0)
70const showDone = atom({ plugin: 'agent-watch', key: 'showDone' } as const, false)
71const dropped = atom({ plugin: 'agent-watch', key: 'dropped' } as const, [] as string[])
72const expanded = atom({ plugin: 'agent-watch', key: 'expanded' } as const, [] as string[])
73const sortBy = atom({ plugin: 'agent-watch', key: 'sortBy' } as const, 'start' as SortBy)
74const offset = atom({ plugin: 'agent-watch', key: 'offset' } as const, 0)
75const waveStart = atom({ plugin: 'agent-watch', key: 'waveStart' } as const, null as number | null)
76
77/** Colors of the share bar's parts, heaviest first; the rest of the agents are dim. */
78const SHARE_COLORS = ['claude', 'success', 'warning', 'error'] as const
79// The last first row the list can start at, from the last render: the wheel stops there.
80let lastStart = 0
81
82
83let shownStatus: string | undefined
84let stallMs = 5 * 60_000
85// Set by register from the options, and by session.start from the system's LANG and terminal.
86let lang: Lang = 'en'
87let style: IconStyle = 'emoji'
88
89const warn = () => glyph(style, { emoji: '⚠', symbol: '!' })
90
91/** Edits the agents and the orphans together. */
92const edit = async ($: EngineInterface, fn: (list: Agent[], early: Record<string, Tokens>) => { agents: Agent[]; orphans: Record<string, Tokens> }) => {
93 const known = await read($, orphans)
94 let left = known
95 await update($, agents, list => {
96 const next = fn(list, known)
97 left = next.orphans
98 return next.agents
99 })
100 await update($, orphans, () => left)
101}
102
103/** Folds the agent listing in, raises stall toasts, ends a wave, and sets the status line. */
104const refresh = async ($: EngineInterface) => {
105 const w = WORDS[lang]
106 const now = await $.clock.now()
107 // A finished agent the person cleared stays out, though the listing still names it.
108 const gone = await read($, dropped)
109 const listed = (await $.agent.list().catch(() => [])).filter(l => isActive(l.status) || !gone.includes(l.id))
110 const known = await read($, orphans)
111 let left = known
112 let raised: Agent[] = []
113 await update($, agents, list => {
114 const folded = reconcile(list, known, listed, now)
115 left = folded.orphans
116 const checked = stalls(folded.agents, now, stallMs)
117 raised = checked.raised
118 return checked.agents
119 })
120 if (left !== known) await update($, orphans, () => left)
121 for (const a of raised) $.ui.toast(w.stalledToast(nameOf(a), stallText(a, now, lang)), { timeoutMs: 10_000 })
122
123 const list = await read($, agents)
124 const active = list.filter(a => isActive(a.status) && !a.id.startsWith(DEMO)).length
125 let before = 0
126 await update($, live, n => {
127 before = n
128 return active
129 })
130 if (list.some(a => isActive(a.status))) await update($, tick, n => n + 1)
131 if (before === 0 && active > 0) {
132 const start = fiveHourOf(await $.session.usage().catch(() => null))
133 await update($, waveStart, () => start)
134 }
135 if (before > 0 && active === 0) {
136 const since = await read($, standDownAt)
137 const wave = summarize(list.filter(a => a.startedAt > since && !a.id.startsWith(DEMO)), now)
138 await update($, standDownAt, () => now)
139 const used = windowUsed(await read($, waveStart), fiveHourOf(await $.session.usage().catch(() => null)))
140 const run = wave && { ...wave, windowUsed: used }
141 if (run) {
142 await update($, lastRun, () => run)
143 $.ui.toast(w.doneToast(runText(run, lang)), { timeoutMs: 10_000 })
144 }
145 }
146
147 const running = list.filter(a => isActive(a.status))
148 const stalled = running.filter(a => a.isStalled).length
149 const text = running.length ? w.statusLine(running.length, tokens(running.reduce((n, a) => n + total(a.tokens), 0)), stalled, warn()) : undefined
150 if (text !== shownStatus) {
151 shownStatus = text
152 $.ui.status(text)
153 }
154}
155
156const quietly = ($: EngineInterface) => void refresh($).catch(() => undefined)
157
158const open = ($: EngineInterface) => $.ui.open({ id: PANE, title: WORDS[lang].pane }).catch(() => null)
159
160const clear = async ($: EngineInterface, kind: 'done' | 'demo' | 'both') => {
161 let removed: string[] = []
162 await update($, agents, list => {
163 const kept = clearOut(list, kind, DEMO)
164 removed = list.filter(a => !kept.includes(a)).map(a => a.id)
165 return kept
166 })
167 await update($, dropped, ids => [...ids, ...removed].slice(-200))
168 quietly($)
169}
170
171const runDemo = async ($: EngineInterface) => {
172 const now = await $.clock.now()
173 const [first, second, third] = WORDS[lang].demoLabels
174 const t = (input: number, output: number, cacheRead: number) => ({ input, output, cacheRead, cacheWrite: 0 })
175 const demo: Agent[] = [
176 { id: `${DEMO}1`, label: first, type: 'Explore', model: 'claude-haiku-4-5', status: 'running', startedAt: now - 140_000, lastAt: now - 4000, activity: 'tool', doing: PHRASES[lang].reading('routes.ts'), tools: 14, errors: 0, steps: 15, tokens: t(9_000, 6_200, 118_000), isStalled: false },
177 { id: `${DEMO}2`, label: second, type: 'general-purpose', model: 'claude-opus-5-5', status: 'running', startedAt: now - 600_000, lastAt: now - stallMs - 60_000, activity: 'tool', doing: PHRASES[lang].running('npm test'), tools: 9, errors: 2, steps: 10, tokens: t(14_000, 8_100, 210_000), isStalled: false },
178 { id: `${DEMO}3`, label: third, type: 'Explore', parentId: `${DEMO}1`, model: 'claude-haiku-4-5', status: 'completed', startedAt: now - 90_000, endedAt: now - 30_000, lastAt: now - 30_000, activity: 'idle', tools: 6, errors: 0, steps: 7, tokens: t(3_000, 1_900, 41_000), isStalled: false },
179 ]
180 await update($, agents, list => [...list.filter(a => !a.id.startsWith(DEMO)), ...demo])
181 await open($)
182 await refresh($)
183}
184
185/** /watch and its arguments: what the command answers, and what the pane's verbs run. */
186const runCommand = async ($: EngineInterface, args: string): Promise<{ text?: string }> => {
187 const w = WORDS[lang]
188 switch (args.trim().toLowerCase().replace(/\s+/g, ' ')) {
189 case 'clear':
190 await clear($, 'both')
191 return { text: w.clearedBoth }
192 case 'clear done':
193 await clear($, 'done')
194 return { text: w.clearedDone }
195 case 'clear demo':
196 await clear($, 'demo')
197 return { text: w.clearedDemo }
198 case 'demo':
199 await runDemo($)
200 return { text: w.demoAdded }
201 case 'help':
202 return { text: w.help }
203 case '': {
204 const opened = await open($)
205 await refresh($).catch(() => undefined)
206 if (opened?.isPlaced) return {}
207 const list = await read($, agents)
208 const run = await read($, lastRun)
209 const running = list.filter(a => isActive(a.status))
210 return {
211 text: `${w.answer(running.length, list.length - running.length)}${running.map(a => ` ${nameOf(a)} ${tokens(total(a.tokens))}.`).join('')}${run ? ` ${w.lastRunAnswer(runText(run, lang))}` : ''}`,
212 }
213 }
214 default:
215 return { text: w.help }
216 }
217}
218
219/** Puts a text in the prompt for the person to send; runs nothing. */
220const fill = async ($: EngineInterface, text: string) => {
221 await $.prompt.fill(fillArgs(text))
222}
223
224/** The pane's verbs: clear drops agents, so it waits in the prompt. */
225const VERBS: readonly Verb[] = [{ verb: 'clear', fill: `/${COMMAND} clear` }, { verb: 'demo' }, { verb: 'help' }]
226
227/** A verb pressed in the pane: fills the prompt, or runs and writes its answer to the transcript. */
228const pressVerb = async ($: EngineInterface, v: Verb) => {
229 try {
230 if (v.fill) return await fill($, v.fill)
231 const { text } = await runCommand($, v.verb)
232 for (const line of linesOf(text)) $.ui.log(line)
233 } catch {
234 $.ui.toast(WORDS[lang].failedToRun(`/${COMMAND} ${v.verb}`))
235 }
236}
237
238export const register: Register = (on, options) => {
239 stallMs = Math.max(1, Number(options.stallMinutes) || 5) * 60_000
240 lang = langOf(options.language)
241 style = styleOf(options.icons)
242
243 on('session.start', async ($, e, next) => {
244 const result = await next(e)
245 lang = langOf(options.language, await $.env.get('LANG').catch(() => undefined))
246 style = styleOf(options.icons, await $.env.get('TERMINAL_EMULATOR').catch(() => undefined))
247 await $.command.register({
248 name: COMMAND,
249 description: WORDS[lang].description,
250 argumentHint: '[clear [done|demo]|demo|help]',
251 immediate: true,
252 })
253 $.clock.every(POLL_MS, () => quietly($))
254 return result
255 })
256
257 on('agent.spawn', async ($, e, next) => {
258 const result = await next(e)
259 const id = result.agentId
260 if (result.deny !== undefined || !id) return result
261 const now = await $.clock.now()
262 await edit($, (list, early) =>
263 adopt(list, early, id, now, {
264 label: e.description || e.subagentType,
265 type: e.fork ? 'fork' : e.subagentType,
266 model: result.model,
267 status: 'running',
268 startedAt: now,
269 lastAt: now,
270 ...(e.name ? { name: e.name } : {}),
271 ...(e.parentAgentId ? { parentId: e.parentAgentId } : {}),
272 }),
273 )
274 quietly($)
275 return result
276 }).catch(($, e, next) => next(e)) // an observer: fail open, the spawn goes on
277
278 // Every model request: the stall clock reads "thinking" while it is out, and its tokens land
279 // on the loop's agent (or the lead) when it is back.
280 on('turn.step', async function* ($, e, next) {
281 const id = e.agentId
282 if (id) {
283 const now = await $.clock.now()
284 await update($, agents, list => touch(list, id, now, 'thinking'))
285 }
286 const result = yield* next(e)
287 const usage = result.usage
288 if (usage) {
289 const spent = fromUsage(usage)
290 if (id) {
291 const now = await $.clock.now()
292 await edit($, (list, early) => addStep(list, early, id, spent, usage.model, now))
293 } else await update($, lead, t => add(t, spent))
294 }
295 return result
296 })
297
298 on('tool.call', async ($, e, next) => {
299 const id = e.agentId
300 if (!id) return next(e)
301 const now = await $.clock.now()
302 await update($, agents, list => toolStart(list, id, phraseOf(e.tool, e as unknown as Record<string, unknown>, lang), now))
303 let ok = false
304 try {
305 const ran = await next(e)
306 ok = ran.deny === undefined && ran.isError !== true
307 return ran
308 } finally {
309 const end = await $.clock.now()
310 await update($, agents, list => toolEnd(list, id, ok, end))
311 }
312 }).catch(($, e, next) => next(e)) // an observer: fail open, the call goes on
313
314 // A subagent's run ends in its own turn.complete: close it and refresh now.
315 on('turn.complete', async ($, e, next) => {
316 const result = await next(e)
317 const id = e.agentId
318 if (id) {
319 const now = await $.clock.now()
320 await update($, agents, list => finish(list, id, e.reason, now))
321 quietly($)
322 }
323 return result
324 })
325
326 on('command.run', { command: COMMAND }, ($, e) => runCommand($, e.args))
327
328 // The wheel over the pane moves the agent list.
329 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
330 await update($, offset, o => Math.max(0, Math.min(lastStart, o + e.by)))
331 return {}
332 }).catch(($, e, next) => next(e))
333
334 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
335 const { Box, Text, Button } = $.ui.resolve(e)
336 const w = WORDS[lang]
337 const n = await read($, tick)
338 const isOpen = await read($, showDone)
339 const opened = await read($, expanded)
340 const order = await read($, sortBy)
341 const now = await $.clock.now()
342 const list = await read($, agents)
343 const spentLead = await read($, lead)
344 const other = Object.values(await read($, orphans)).reduce((t, x) => add(t, x), ZERO)
345 const run = await read($, lastRun)
346 const width = Math.max(40, (e.props.bodyColumns || e.viewport?.columns || 80) - 2)
347 const isWide = width >= 90
348
349 const running = list.filter(a => isActive(a.status))
350 const done = list.length - running.length
351 const stalled = running.filter(a => a.isStalled).length
352 const hasDemo = list.some(a => a.id.startsWith(DEMO))
353 const sum = list.reduce((s, a) => s + total(a.tokens), 0)
354 const tone = (a: Agent) => (a.isStalled && isActive(a.status) ? 'warning' : isActive(a.status) ? 'claude' : isFailed(a.status) ? 'error' : 'success')
355 const rows = tree(isOpen ? list : running, order)
356 const parts = shares(list, Math.min(60, width - 4))
357 const colorOf = (a: Agent | null) => {
358 const i = parts.findIndex(p => p.agent === a)
359 return a === null || i < 0 ? undefined : SHARE_COLORS[i % SHARE_COLORS.length]
360 }
361 // Two lines an agent, and the open ones' tool calls. The rest of the pane: hint, counts,
362 // the share bar and its legend, the lead, the folded line, other loops, the last run, the
363 // scroll row, the footer and the gaps.
364 const extra = rows.filter(r => opened.includes(r.agent.id)).reduce((k, r) => k + 1 + Math.max(1, r.agent.recent?.length ?? 0), 0)
365 const fixed = 16 + (parts.length ? 2 : 0)
366 const listRows = Math.max(2, Math.floor(((e.props.scroll?.bodyRows ?? e.viewport?.rows ?? 40) - fixed - extra) / 2))
367 const view = windowOf(rows.length, await read($, offset), listRows)
368 lastStart = Math.max(0, rows.length - listRows)
369 const scrollList = (by: number) => update($, offset, o => windowOf(rows.length, o + by, listRows).start)
370 const toggle = (id: string) => update($, expanded, ids => (ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id]))
371 const investigate = (a: Agent) =>
372 fill($, w.askInvestigate(nameOf(a), a.activity === 'thinking' ? w.thinking : (a.doing ?? w.inATool), elapsed(quietFor(a, now))))
373
374 return (
375 <Box flexDirection="column" paddingX={1} gap={1}>
376 <Box flexDirection="column">
377 <Text dimColor>{w.hint}</Text>
378 <Text>
379 <Text dimColor>{w.counts(running.length, done, tokens(sum))}</Text>
380 {stalled > 0 && <Text color="warning" bold>{` ${warn()} ${w.stalled(stalled)}`}</Text>}
381 </Text>
382 </Box>
383
384 {parts.length > 0 && (
385 <Box flexDirection="column">
386 <Text>
387 {parts.map(p => (
388 <Text key={`share:${p.agent?.id ?? 'rest'}`} color={colorOf(p.agent)} dimColor={p.agent === null}>
389 {(p.agent === null ? '░' : '█').repeat(p.cells)}
390 </Text>
391 ))}
392 </Text>
393 <Box flexDirection="row" flexWrap="wrap" gap={2}>
394 {parts.map(p => (
395 <Text key={`legend:${p.agent?.id ?? 'rest'}`}>
396 <Text color={colorOf(p.agent)} dimColor={p.agent === null}>{p.agent === null ? '░ ' : '█ '}</Text>
397 <Text dimColor>{`${clip(p.agent ? nameOf(p.agent) : w.others, 20)} ${Math.round((p.tokens / sum) * 100)}%`}</Text>
398 </Text>
399 ))}
400 </Box>
401 </Box>
402 )}
403
404 <Box flexDirection="column">
405 <Box flexDirection="row" gap={2}>
406 <Text>
407 <Text bold>{`◆ ${w.lead}`}</Text>
408 <Text dimColor>{` ${w.leadLine(tokens(total(spentLead)), tokens(spentLead.output))}`}</Text>
409 </Text>
410 {rows.length > 1 && (
411 <Button key="sort" plain dimColor label={`⇅ ${order === 'start' ? w.sortStart : w.sortTokens}`} onPress={() => update($, sortBy, x => (x === 'start' ? 'tokens' : 'start'))} />
412 )}
413 </Box>
414 {list.length === 0 && <Text dimColor>{` ${w.empty} ${w.emptyNext}`}</Text>}
415 {rows.slice(view.start, view.end).map(({ agent: a, prefix, rail }) => {
416 const isStuck = a.isStalled && isActive(a.status)
417 const state = isStuck ? stallText(a, now, lang) : isActive(a.status) ? (a.activity === 'thinking' ? w.thinking : (a.doing ?? w.starting)) : (w.statuses[a.status] ?? a.status)
418 const clock = isActive(a.status) ? elapsed(now - a.startedAt) : elapsed((a.endedAt ?? now) - a.startedAt)
419 const meta = `${a.type === 'general-purpose' ? 'general' : a.type}${a.model ? ` · ${shortModel(a.model)}` : ''}`
420 const isExpanded = opened.includes(a.id)
421 return (
422 <Box key={a.id} flexDirection="column">
423 <Box flexDirection="row">
424 <Text dimColor>{prefix}</Text>
425 <Text color={tone(a)} bold>{` ${glyphOf(a, n)} `}</Text>
426 <Button key={`open:${a.id}`} plain label={`${nameOf(a).slice(0, 28)} ${isExpanded ? '▾' : '▸'}`} onPress={() => toggle(a.id)} />
427 <Text dimColor>{` ${meta}`}</Text>
428 {colorOf(a) && <Text color={colorOf(a)}>{' ■'}</Text>}
429 {a.id.startsWith(DEMO) && <Text color="warning">{` ${w.demoBadge}`}</Text>}
430 {isStuck && <Text>{' '}</Text>}
431 {isStuck && <Button key={`investigate:${a.id}`} plain color="warning" label={w.investigate} onPress={() => investigate(a)} />}
432 </Box>
433 <Text>
434 <Text dimColor>{`${rail} `}</Text>
435 <Text bold>{tokens(total(a.tokens)).padStart(5)}</Text>
436 <Text dimColor>{isWide ? ` in ${tokens(a.tokens.input + a.tokens.cacheRead + a.tokens.cacheWrite)} out ${tokens(a.tokens.output)}` : ''}</Text>
437 <Text dimColor>{` ${w.tools(a.tools, a.errors)} ${clock} `}</Text>
438 <Text color={isStuck ? 'warning' : undefined} dimColor={!isStuck}>
439 {state.slice(0, Math.max(10, width - 50))}
440 </Text>
441 </Text>
442 {isExpanded && (
443 <Box flexDirection="column">
444 <Text dimColor>{`${rail} ${w.recentTitle}`}</Text>
445 {(a.recent ?? []).length === 0 && <Text dimColor>{`${rail} ${w.noRecent}`}</Text>}
446 {(a.recent ?? []).map((r, i) => (
447 <Text key={`recent:${a.id}:${i}`}>
448 <Text dimColor>{`${rail} `}</Text>
449 <Text color={r.ok === false ? 'error' : r.ok === null ? 'claude' : 'success'}>{r.ok === false ? '× ' : r.ok === null ? '… ' : '✓ '}</Text>
450 <Text dimColor={r.ok !== false}>{clip(r.doing, Math.max(10, width - rail.length - 12))}</Text>
451 {r.ok === false && <Text color="error">{` ${w.failedMark}`}</Text>}
452 </Text>
453 ))}
454 </Box>
455 )}
456 </Box>
457 )
458 })}
459 {rows.length > listRows && (
460 <Box flexDirection="row" gap={2}>
461 <Button key="list:up" plain dimColor={view.start === 0} label={w.up} onPress={() => scrollList(-listRows + 1)} />
462 <Button key="list:down" plain dimColor={view.end === rows.length} label={w.down} onPress={() => scrollList(listRows - 1)} />
463 <Text dimColor>{w.range(view.start + 1, view.end, rows.length)}</Text>
464 </Box>
465 )}
466 {done > 0 && (
467 <Box flexDirection="row" gap={2}>
468 <Text color="success">{' ✓'}</Text>
469 <Button key="done" plain label={w.finished(done, isOpen)} onPress={() => update($, showDone, x => !x)} />
470 <Button key="clear-done" plain dimColor label={w.clearDone} onPress={() => clear($, 'done')} />
471 </Box>
472 )}
473 {total(other) > 0 && <Text dimColor>{` ${w.otherLoops(tokens(total(other)))}`}</Text>}
474 </Box>
475
476 {run && (
477 <Text>
478 <Text bold>{`${w.lastRun} `}</Text>
479 <Text dimColor>{runText(run, lang)}</Text>
480 </Text>
481 )}
482
483 <Box flexDirection="row" flexWrap="wrap" gap={2}>
484 {verbRow({ Box, Text, Button }, COMMAND, VERBS, v => pressVerb($, v))}
485 {hasDemo && <Button key="clear-demo" plain dimColor label={w.clearDemo} onPress={() => clear($, 'demo')} />}
486 <Button key="close" role="dismiss" label={w.close} onPress={() => $.ui.close({ id: PANE })} />
487 </Box>
488 </Box>
489 )
490 })
491}
492hooks/watch.ts 287 lines1// Pure agent bookkeeping: no engine, so the tests drive it directly.
2
3import type { Activity, Agent, Run, Tokens } from '../types'
4import type { Lang } from './ui'
5import { elapsed, tokens } from './ui'
6import { WORDS } from './words'
7
8const MAX_AGENTS = 60
9/** Tool calls an agent row keeps for its expanded view. */
10export const RECENT_KEPT = 5
11
12export const ZERO: Tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
13
14export type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
15
16export type Listed = { id: string; description: string; type: string; status: string; parentId?: string; name?: string }
17
18export const fromUsage = (u: Usage): Tokens => ({
19 input: u.input_tokens,
20 output: u.output_tokens,
21 cacheRead: u.cache_read_input_tokens,
22 cacheWrite: u.cache_creation_input_tokens,
23})
24
25export const add = (a: Tokens, b: Tokens): Tokens => ({
26 input: a.input + b.input,
27 output: a.output + b.output,
28 cacheRead: a.cacheRead + b.cacheRead,
29 cacheWrite: a.cacheWrite + b.cacheWrite,
30})
31
32export const total = (t: Tokens) => t.input + t.output + t.cacheRead + t.cacheWrite
33
34/** Running a turn, about to, or held on its own background work. */
35export const isActive = (status: string) => status === 'running' || status === 'pending' || status === 'waiting'
36export const isFailed = (status: string) => status === 'failed' || status === 'killed'
37
38const blank = (id: string, now: number, patch: Partial<Agent>): Agent => ({
39 id,
40 label: 'agent',
41 type: 'agent',
42 status: 'running',
43 startedAt: now,
44 lastAt: now,
45 activity: 'idle',
46 tools: 0,
47 errors: 0,
48 steps: 0,
49 tokens: ZERO,
50 isStalled: false,
51 ...patch,
52})
53
54export const upsert = (list: readonly Agent[], id: string, now: number, patch: Partial<Agent>): Agent[] => {
55 const i = list.findIndex(a => a.id === id)
56 if (i < 0) {
57 const next = [...list, blank(id, now, patch)]
58 return next.length > MAX_AGENTS ? next.slice(next.length - MAX_AGENTS) : next
59 }
60 const next = list.slice()
61 next[i] = { ...next[i]!, ...patch }
62 return next
63}
64
65/** A new agent, taking the tokens its loop spent before it was known. */
66export const adopt = (
67 list: readonly Agent[],
68 orphans: Readonly<Record<string, Tokens>>,
69 id: string,
70 now: number,
71 patch: Partial<Agent>,
72): { agents: Agent[]; orphans: Record<string, Tokens> } => {
73 const early = orphans[id]
74 const had = list.find(a => a.id === id)
75 const agents = upsert(list, id, now, early ? { ...patch, tokens: add(had?.tokens ?? ZERO, early) } : patch)
76 if (!early) return { agents, orphans: { ...orphans } }
77 const { [id]: _, ...rest } = orphans
78 return { agents, orphans: rest }
79}
80
81/** Something happened in the loop: the stall clock restarts. */
82export const touch = (list: readonly Agent[], id: string, now: number, activity: Activity, patch: Partial<Agent> = {}): Agent[] =>
83 list.some(a => a.id === id) ? upsert(list, id, now, { ...patch, activity, lastAt: now, isStalled: false }) : (list as Agent[])
84
85/** A model request ended: its tokens go to the agent, or to the orphans while no agent has the id. */
86export const addStep = (
87 list: readonly Agent[],
88 orphans: Readonly<Record<string, Tokens>>,
89 id: string,
90 tokens: Tokens,
91 model: string,
92 now: number,
93): { agents: Agent[]; orphans: Record<string, Tokens> } => {
94 const a = list.find(x => x.id === id)
95 if (!a) return { agents: list as Agent[], orphans: { ...orphans, [id]: add(orphans[id] ?? ZERO, tokens) } }
96 return { agents: touch(list, id, now, 'idle', { tokens: add(a.tokens, tokens), steps: a.steps + 1, model }), orphans: { ...orphans } }
97}
98
99export const toolStart = (list: readonly Agent[], id: string, doing: string, now: number): Agent[] => {
100 const a = list.find(x => x.id === id)
101 const recent = [...(a?.recent ?? []), { doing, ok: null }].slice(-RECENT_KEPT)
102 return a ? touch(list, id, now, 'tool', { doing, tools: a.tools + 1, recent }) : (list as Agent[])
103}
104
105export const toolEnd = (list: readonly Agent[], id: string, ok: boolean, now: number): Agent[] => {
106 const a = list.find(x => x.id === id)
107 if (!a) return list as Agent[]
108 // The call that ends is the last one still open.
109 const recent = [...(a.recent ?? [])]
110 const open = recent.map(r => r.ok).lastIndexOf(null)
111 if (open >= 0) recent[open] = { ...recent[open]!, ok }
112 return touch(list, id, now, 'idle', { errors: a.errors + (ok ? 0 : 1), recent })
113}
114
115/**
116 * A subagent's run ended in its `turn.complete`. Closed here because the listing may drop the
117 * agent before a poll sees its final status. Teammates end a turn without ending.
118 */
119export const finish = (list: readonly Agent[], id: string, reason: string, now: number): Agent[] => {
120 const a = list.find(x => x.id === id)
121 if (!a || a.type === 'teammate' || !isActive(a.status)) return list as Agent[]
122 const status = reason === 'aborted' ? 'killed' : reason === 'answer' ? 'completed' : 'failed'
123 return upsert(list, id, now, { status, endedAt: now, activity: 'idle' })
124}
125
126/**
127 * Folds `$.agent.list()` in: new agents adopt their early tokens, statuses move, and agents
128 * that left the active statuses get an end time. An agent the listing dropped keeps its row.
129 */
130export const reconcile = (
131 list: readonly Agent[],
132 orphans: Readonly<Record<string, Tokens>>,
133 listed: readonly Listed[],
134 now: number,
135): { agents: Agent[]; orphans: Record<string, Tokens>; finished: Agent[] } => {
136 let agents = list as Agent[]
137 let left = { ...orphans }
138 const finished: Agent[] = []
139 for (const l of listed) {
140 const had = agents.find(a => a.id === l.id)
141 // A listing that lags behind finish() must not bring the agent back; activity after the end does.
142 const isLagging = had?.endedAt !== undefined && isActive(l.status) && had.lastAt <= had.endedAt
143 const patch: Partial<Agent> = {
144 ...(isLagging ? {} : { status: l.status }),
145 ...(had ? {} : { label: l.description || l.name || l.type, type: l.type }),
146 ...(l.name ? { name: l.name } : {}),
147 ...(l.parentId ? { parentId: l.parentId } : {}),
148 }
149 if (had && isActive(had.status) && !isActive(l.status)) {
150 Object.assign(patch, { endedAt: now, activity: 'idle' as const })
151 finished.push({ ...had, ...patch })
152 }
153 if (!had && !isActive(l.status)) patch.endedAt = now
154 if (had?.endedAt !== undefined && isActive(l.status) && !isLagging) patch.endedAt = undefined
155 const next = adopt(agents, left, l.id, now, patch)
156 agents = next.agents
157 left = next.orphans
158 }
159 return { agents, orphans: left, finished }
160}
161
162export const quietFor = (a: Agent, now: number) => Math.max(0, now - a.lastAt)
163
164/**
165 * Marks running agents quiet for `thresholdMs` or longer as stalled, once per quiet spell.
166 * Only `running` counts: a teammate between turns is `idle`, and `waiting` is held on purpose.
167 */
168export const stalls = (list: readonly Agent[], now: number, thresholdMs: number): { agents: Agent[]; raised: Agent[] } => {
169 const raised: Agent[] = []
170 const agents = list.map(a => {
171 if (a.isStalled || a.status !== 'running' || quietFor(a, now) < thresholdMs) return a
172 const marked = { ...a, isStalled: true }
173 raised.push(marked)
174 return marked
175 })
176 return { agents: raised.length ? agents : (list as Agent[]), raised }
177}
178
179/** "thinking for 6m", "in Bash (running npm test) for 6m", "quiet for 6m". */
180export const stallText = (a: Agent, now: number, lang: Lang = 'en') => {
181 const w = WORDS[lang]
182 const span = elapsed(quietFor(a, now))
183 if (a.activity === 'thinking') return w.thinkingFor(span)
184 if (a.activity === 'tool') return w.doingFor(a.doing ?? w.inATool, span)
185 return w.quietFor(span)
186}
187
188/** What a wave of agents came to. Null when the wave had none. */
189export const summarize = (wave: readonly Agent[], now: number): Run | null => {
190 if (wave.length === 0) return null
191 const sum = wave.reduce((n, a) => n + total(a.tokens), 0)
192 const heaviest = wave.reduce((best, a) => (total(a.tokens) > total(best.tokens) ? a : best), wave[0]!)
193 const first = Math.min(...wave.map(a => a.startedAt))
194 const last = Math.max(...wave.map(a => a.endedAt ?? now))
195 return {
196 endedAt: now,
197 count: wave.length,
198 durationMs: last - first,
199 total: sum,
200 top: total(heaviest.tokens) > 0 ? { label: nameOf(heaviest), total: total(heaviest.tokens), share: total(heaviest.tokens) / sum } : null,
201 }
202}
203
204export const runText = (r: Run, lang: Lang = 'en') =>
205 WORDS[lang].run(r.count, tokens(r.total), elapsed(r.durationMs)) +
206 (r.top ? WORDS[lang].heaviest(r.top.label, tokens(r.top.total), Math.round(r.top.share * 100)) : '') +
207 (r.windowUsed != null && r.windowUsed >= 1 ? WORDS[lang].windowUsed(Math.round(r.windowUsed)) : '')
208
209/** Points of the 5-hour window a wave used, from its reading at the start and at the end; null without both. */
210export const windowUsed = (start: number | null, end: number | null) => (start === null || end === null || end < start ? null : end - start)
211
212/** The 5-hour window's percent in a usage reading, or null. */
213export const fiveHourOf = (usage: { rateLimits?: readonly { kind: string; percentUsed: number }[] } | null | undefined) =>
214 usage?.rateLimits?.find(r => r.kind === 'five_hour')?.percentUsed ?? null
215
216/** One part of the share bar: an agent, or the rest together (`agent` null), and its cells. */
217export type Share = { agent: Agent | null; tokens: number; cells: number }
218
219/**
220 * Each agent's part of `width` cells by tokens: the `top` heaviest on their own, the rest as
221 * one part. Cells go by largest remainder, so they add up to `width`; an agent with tokens gets
222 * a cell at least when there is room.
223 */
224export const shares = (list: readonly Agent[], width: number, top = 4): Share[] => {
225 const withTokens = list.filter(a => total(a.tokens) > 0).sort((x, y) => total(y.tokens) - total(x.tokens))
226 const sum = withTokens.reduce((n, a) => n + total(a.tokens), 0)
227 if (sum === 0 || width <= 0) return []
228 const parts: Share[] = withTokens.slice(0, top).map(a => ({ agent: a, tokens: total(a.tokens), cells: 0 }))
229 const rest = withTokens.slice(top).reduce((n, a) => n + total(a.tokens), 0)
230 if (rest > 0) parts.push({ agent: null, tokens: rest, cells: 0 })
231 const exact = parts.map(p => (p.tokens / sum) * width)
232 parts.forEach((p, i) => (p.cells = Math.floor(exact[i]!)))
233 let left = width - parts.reduce((n, p) => n + p.cells, 0)
234 const order = parts.map((_, i) => i).sort((i, j) => exact[j]! - Math.floor(exact[j]!) - (exact[i]! - Math.floor(exact[i]!)))
235 for (const i of order) {
236 if (left === 0) break
237 parts[i]!.cells++
238 left--
239 }
240 return parts
241}
242
243/** How the pane orders agents among their siblings. */
244export type SortBy = 'start' | 'tokens'
245
246export const nameOf = (a: Agent) => a.name || a.label
247
248/** `prefix` leads the agent's first line, `rail` its second. */
249export type Row = { agent: Agent; prefix: string; rail: string }
250
251/** Depth-first, children under their parent by start time (or most tokens first), with tree connectors. */
252export const tree = (list: readonly Agent[], sortBy: SortBy = 'start'): Row[] => {
253 const ids = new Set(list.map(a => a.id))
254 const kids = new Map<string, Agent[]>()
255 for (const a of list) {
256 const p = a.parentId && ids.has(a.parentId) ? a.parentId : ''
257 kids.set(p, [...(kids.get(p) ?? []), a])
258 }
259 const rows: Row[] = []
260 const walk = (parent: string, rail: string) => {
261 const children = (kids.get(parent) ?? []).sort((x, y) => (sortBy === 'tokens' ? total(y.tokens) - total(x.tokens) : 0) || x.startedAt - y.startedAt)
262 children.forEach((a, i) => {
263 const last = i === children.length - 1
264 const below = rail + (last ? ' ' : '│ ')
265 rows.push({ agent: a, prefix: rail + (last ? '└─' : '├─'), rail: below })
266 walk(a.id, below)
267 })
268 }
269 walk('', '')
270 return rows
271}
272
273/** The agents left after a clear: `done` drops the finished ones, `demo` the demo ones, `both` both. */
274export const clearOut = (list: readonly Agent[], kind: 'done' | 'demo' | 'both', demoPrefix: string): Agent[] =>
275 list.filter(a => {
276 const isDemo = a.id.startsWith(demoPrefix)
277 if (kind === 'demo') return !isDemo
278 if (kind === 'done') return isActive(a.status)
279 return isActive(a.status) && !isDemo
280 })
281
282const SPINNER = '◐◓◑◒'
283
284/** The glyph of an agent: a spinner while it runs, `!` stalled, ✓ done, × failed. */
285export const glyphOf = (a: Agent, tick: number) =>
286 isActive(a.status) ? (a.isStalled ? '!' : SPINNER[tick % SPINNER.length]!) : isFailed(a.status) ? '×' : '✓'
287hooks/phrases.ts 87 lines1// A tool call in a few words, in Portuguese and English: `lendo routes.ts`, `buscando "fatura"`.
2// The same file in every mod that names tool calls: scripts/check-shared.sh keeps the copies
3// equal, so it imports from nothing but ui.tsx, which is shared too. Phrases start in lower case;
4// a mod that shows one alone capitalizes it.
5
6import type { Lang } from './ui'
7import { clip } from './ui'
8
9type Phrases = {
10 running: (what: string) => string
11 reading: (file: string) => string
12 writing: (file: string) => string
13 editing: (file: string) => string
14 searching: (pattern: string) => string
15 finding: (pattern: string) => string
16 web: string
17 delegating: (what: string) => string
18 calling: (tool: string) => string
19 /** Stand-ins for an argument the call left out. */
20 aCommand: string
21 aFile: string
22 aTask: string
23}
24
25export const PHRASES: Record<Lang, Phrases> = {
26 'pt-BR': {
27 running: what => `rodando ${what}`,
28 reading: file => `lendo ${file}`,
29 writing: file => `escrevendo ${file}`,
30 editing: file => `editando ${file}`,
31 searching: pattern => `buscando "${pattern}"`,
32 finding: pattern => `procurando ${pattern}`,
33 web: 'na web',
34 delegating: what => `delegando "${what}"`,
35 calling: tool => `chamando ${tool}`,
36 aCommand: 'um comando',
37 aFile: 'um arquivo',
38 aTask: 'uma tarefa',
39 },
40 en: {
41 running: what => `running ${what}`,
42 reading: file => `reading ${file}`,
43 writing: file => `writing ${file}`,
44 editing: file => `editing ${file}`,
45 searching: pattern => `searching "${pattern}"`,
46 finding: pattern => `finding ${pattern}`,
47 web: 'on the web',
48 delegating: what => `delegating "${what}"`,
49 calling: tool => `calling ${tool}`,
50 aCommand: 'a command',
51 aFile: 'a file',
52 aTask: 'a task',
53 },
54}
55
56/** The last part of a path, `/` or `\` separated. */
57const base = (p: unknown) => (typeof p === 'string' ? (p.split(/[/\\]/).filter(Boolean).pop() ?? '') : '')
58
59/** A tool call in a few words: the tool's verb and its main argument. */
60export const phraseOf = (tool: string, input: Readonly<Record<string, unknown>>, lang: Lang = 'en'): string => {
61 const w = PHRASES[lang]
62 const s = (k: string) => (typeof input[k] === 'string' ? (input[k] as string).trim() : '')
63 const file = () => base(input.file_path) || w.aFile
64 switch (tool) {
65 case 'Bash':
66 return w.running(clip(s('description') || s('command').split('\n')[0]!.trim() || w.aCommand, 50))
67 case 'Read':
68 return w.reading(file())
69 case 'Write':
70 return w.writing(file())
71 case 'Edit':
72 case 'MultiEdit':
73 return w.editing(file())
74 case 'Grep':
75 return w.searching(clip(s('pattern'), 30))
76 case 'Glob':
77 return w.finding(clip(s('pattern'), 30))
78 case 'WebFetch':
79 case 'WebSearch':
80 return w.web
81 case 'Agent':
82 return w.delegating(clip(s('description') || w.aTask, 30))
83 default:
84 return w.calling(clip(tool.startsWith('mcp__') ? (tool.split('__').pop() ?? tool) : tool, 30))
85 }
86}
87hooks/ui.tsx 114 lines1// Shared UI helpers, after launchpad's patterns. The same file in every mod that has one: a mod
2// installs alone and cannot import another's code, so scripts/check-shared.sh keeps the copies
3// equal. Change one, copy it to the others.
4//
5// Nothing here takes `$`: the engine follows `$` only into functions of the file that uses it,
6// never across an import. A call on `$` stays in register.tsx; this file gives it its arguments.
7//
8// language and icons the `language` and `icons` options, else the system's LANG and terminal.
9// prompt the arguments of $.prompt.fill: a text with its first `[blank]` marked.
10// lists the window of a long list a pane shows, for ui.scroll.
11// verbs a row of a command's arguments, one press each.
12// numbers tokens, elapsed time, clipped text and short model names.
13
14import type { Elements, PromptFillArgs } from 'claude-code'
15
16export type Lang = 'pt-BR' | 'en'
17export type IconStyle = 'emoji' | 'symbol'
18
19/** The language: the `language` option when it names one, else Portuguese for a pt LANG, else English. */
20export const langOf = (option: unknown, systemLang?: string | null): Lang =>
21 option === 'en' || option === 'pt-BR' ? option : /^pt([_.@-]|$)/i.test(systemLang ?? '') ? 'pt-BR' : 'en'
22
23/**
24 * The icon style: the `icons` option when it names one; on `auto` (or none), symbols in a
25 * JetBrains IDE's terminal (TERMINAL_EMULATOR=JetBrains-JediTerm), which gives many emoji one
26 * column where Claude Code counts two, and emoji everywhere else.
27 */
28export const styleOf = (option: unknown, terminal?: string | null): IconStyle =>
29 option === 'emoji' || option === 'symbol' ? option : /^JetBrains/i.test(terminal ?? '') ? 'symbol' : 'emoji'
30
31/** An icon in the style: its emoji, or the one-cell symbol that stands in for it. */
32export const glyph = (style: IconStyle, icon: { emoji: string; symbol: string }) => icon[style]
33
34const BLANK = /\[[^\]\n]+\]/
35
36/** The first `[blank]` in a text, as offsets, so the prompt can mark what to replace. */
37export const blankIn = (text: string): { start: number; end: number } | null => {
38 const m = BLANK.exec(text)
39 return m ? { start: m.index, end: m.index + m[0].length } : null
40}
41
42/** What `$.prompt.fill` takes to put a text in the prompt, its `[blank]` marked to replace. */
43export const fillArgs = (text: string): PromptFillArgs => {
44 const blank = blankIn(text)
45 return blank ? { text, decorations: [{ ...blank, bold: true, underline: true }] } : { text }
46}
47
48/** The rows of a list of `total` a pane shows from `offset`, kept inside the list. */
49export const windowOf = (total: number, offset: number, rows: number): { start: number; end: number } => {
50 const size = Math.max(1, rows)
51 const start = Math.max(0, Math.min(offset, total - size))
52 return { start, end: Math.min(total, start + size) }
53}
54
55/**
56 * One of a command's arguments in a verb row. `fill` is the text the prompt waits with, for a
57 * verb that takes an argument or undoes something: a stray click then loses nothing. A verb with
58 * no `fill` runs, and its answer goes to the transcript a line at a time (`linesOf`).
59 */
60export type Verb = { verb: string; label?: string; fill?: string }
61
62/** A command's answer as the lines `$.ui.log` writes, one row each; blank lines dropped. */
63export const linesOf = (text: string | undefined) => (text ?? '').split('\n').filter(line => line.trim() !== '')
64
65/** `/command verb · verb · verb`, each verb a plain button; `lead` goes dim before the command. */
66export function verbRow(
67 ui: Pick<Elements[keyof Elements], 'Box' | 'Text' | 'Button'>,
68 command: string,
69 verbs: readonly Verb[],
70 onPress: (v: Verb) => void,
71 lead = '',
72) {
73 const { Box, Text, Button } = ui
74 return (
75 <Box flexDirection="row" flexWrap="wrap">
76 {lead !== '' && <Text dimColor>{`${lead} · `}</Text>}
77 <Text dimColor>{`/${command} `}</Text>
78 {verbs.map((v, i) => (
79 <Box key={`verbrow:${v.verb}`} flexDirection="row">
80 {i > 0 && <Text dimColor> · </Text>}
81 <Button key={`verb:${v.verb}`} plain dimColor label={v.label ?? v.verb} onPress={() => onPress(v)} />
82 </Box>
83 ))}
84 </Box>
85 )
86}
87
88/** 950, 1.2k, 46k, 1.2M. */
89export const tokens = (n: number) => {
90 if (n < 1000) return String(n)
91 if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
92 return `${(n / 1_000_000).toFixed(1)}M`
93}
94
95/** 42s, 6m 05s, 1h 02m. */
96export const elapsed = (ms: number) => {
97 const s = Math.max(0, Math.round(ms / 1000))
98 if (s < 60) return `${s}s`
99 const m = Math.floor(s / 60)
100 if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
101 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
102}
103
104/** The text cut to `n` characters, an ellipsis last when it was longer. */
105export const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
106
107/** claude-haiku-4-5-20251001 -> haiku 4.5 */
108export const shortModel = (m?: string) => {
109 if (!m) return ''
110 const hit = /(opus|sonnet|haiku|fable)[-\s]?(\d+(?:[-.]\d+)?)?/i.exec(m)
111 if (!hit) return m.length > 14 ? `${m.slice(0, 13)}…` : m
112 return `${hit[1]!.toLowerCase()}${hit[2] ? ` ${hit[2].replace('-', '.')}` : ''}`
113}
114hooks/words.ts 189 lines1// What agent-watch says, in Portuguese and English. The language comes from the `language`
2// option, else the system's LANG (ui.tsx's langOf).
3
4import type { Lang } from './ui'
5
6export const COMMAND = 'watch'
7
8type Words = {
9 description: string
10 pane: string
11 hint: string
12 counts: (running: number, finished: number, tokens: string) => string
13 stalled: (n: number) => string
14 lead: string
15 leadLine: (tokens: string, output: string) => string
16 empty: string
17 emptyNext: string
18 otherLoops: (tokens: string) => string
19 lastRun: string
20 tools: (n: number, failed: number) => string
21 thinking: string
22 starting: string
23 /** An agent's status once it ended, or while it waits: the engine's word, in this language. */
24 statuses: Record<string, string>
25 /** The collapsed finished agents: a button that opens or closes them. */
26 finished: (n: number, isOpen: boolean) => string
27 clearDone: string
28 clearDemo: string
29 demoBadge: string
30 close: string
31 statusLine: (n: number, tokens: string, stalled: number, warn: string) => string
32 stalledToast: (name: string, why: string) => string
33 doneToast: (run: string) => string
34 /** What a stalled agent is stuck on. */
35 thinkingFor: (span: string) => string
36 doingFor: (doing: string, span: string) => string
37 inATool: string
38 quietFor: (span: string) => string
39 run: (count: number, tokens: string, span: string) => string
40 heaviest: (label: string, tokens: string, share: number) => string
41 windowUsed: (points: number) => string
42 /** The share bar's legend: the rest of the agents together. */
43 others: string
44 shareTitle: string
45 recentTitle: string
46 noRecent: string
47 failedMark: string
48 /** The button on a stalled agent, and the request it puts in the prompt. */
49 investigate: string
50 askInvestigate: (name: string, doing: string, span: string) => string
51 sortStart: string
52 sortTokens: string
53 range: (from: number, to: number, of: number) => string
54 up: string
55 down: string
56 clearedDone: string
57 clearedDemo: string
58 clearedBoth: string
59 demoAdded: string
60 answer: (running: number, finished: number) => string
61 lastRunAnswer: (run: string) => string
62 demoLabels: readonly [string, string, string]
63 help: string
64 failedToRun: (what: string) => string
65}
66
67const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
68
69export const WORDS: Record<Lang, Words> = {
70 'pt-BR': {
71 description: 'Subagentes: /watch abre o painel; clear [done|demo], demo, help',
72 pane: 'Agentes',
73 hint: 'Subagentes desta sessão: tokens de cada requisição ao modelo, e um toast quando um deles trava.',
74 counts: (r, f, t) => `${r} rodando · ${plural(f, 'concluído', 'concluídos')} · ${t} tokens`,
75 stalled: n => plural(n, 'travado', 'travados'),
76 lead: 'principal',
77 leadLine: (t, out) => `${t} tokens · saída ${out}`,
78 empty: 'Nenhum subagente nesta sessão ainda.',
79 emptyNext: '/watch demo mostra como fica.',
80 otherLoops: t => `outros loops (compactação, memória) ${t}`,
81 lastRun: 'Última rodada',
82 tools: (n, failed) => `${plural(n, 'ferramenta', 'ferramentas')}${failed ? ` (${failed} com erro)` : ''}`,
83 thinking: 'pensando',
84 starting: 'iniciando',
85 statuses: { completed: 'concluído', failed: 'falhou', killed: 'interrompido', idle: 'ocioso', waiting: 'aguardando', pending: 'pendente', running: 'rodando' },
86 finished: (n, isOpen) => `${plural(n, 'concluído', 'concluídos')} ${isOpen ? '▾' : '▸'}`,
87 clearDone: 'limpar concluídos',
88 clearDemo: 'limpar demo',
89 demoBadge: 'demo',
90 close: 'Fechar',
91 statusLine: (n, t, stalled, warn) => `◇ ${plural(n, 'agente', 'agentes')} · ${t}${stalled ? ` · ${warn} ${plural(stalled, 'travado', 'travados')}` : ''}`,
92 stalledToast: (name, why) => `${name} parece travado: ${why}. /watch`,
93 doneToast: run => `Agentes concluídos: ${run}. /watch`,
94 thinkingFor: span => `pensando há ${span}`,
95 doingFor: (doing, span) => `${doing} há ${span}`,
96 inATool: 'numa chamada de ferramenta',
97 quietFor: span => `parado há ${span}`,
98 run: (count, t, span) => `${plural(count, 'agente', 'agentes')}, ${t} tokens em ${span}`,
99 heaviest: (label, t, share) => `. Mais pesado: ${label} ${t} (${share}%)`,
100 windowUsed: p => `. Usou ~${p}% da janela de 5h`,
101 others: 'outros',
102 shareTitle: 'Tokens por agente',
103 recentTitle: 'últimas ferramentas',
104 noRecent: 'nenhuma chamada de ferramenta ainda',
105 failedMark: 'erro',
106 investigate: 'investigar',
107 askInvestigate: (name, doing, span) => `O agente "${name}" parece travado: ${doing}, há ${span}. Verifique o que aconteceu e diga o que fazer.`,
108 sortStart: 'por início',
109 sortTokens: 'por tokens',
110 range: (from, to, of) => `${from}–${to} de ${of}`,
111 up: '▲ acima',
112 down: '▼ abaixo',
113 clearedDone: 'Agentes concluídos removidos.',
114 clearedDemo: 'Agentes de demo removidos.',
115 clearedBoth: 'Agentes concluídos e de demo removidos.',
116 demoAdded: 'Três agentes de demo adicionados; um está travado. /watch clear demo remove.',
117 answer: (r, f) => `${r} rodando, ${plural(f, 'concluído', 'concluídos')}.`,
118 lastRunAnswer: run => `Última rodada: ${run}.`,
119 demoLabels: ['Mapear as rotas da API', 'Corrigir o teste instável', 'Conferir as fontes'],
120 help: [
121 '/watch abre o painel: árvore de agentes com tokens, ferramentas e o que cada um faz',
122 '/watch demo adiciona três agentes falsos, um deles travado',
123 '/watch clear remove os agentes concluídos e os de demo',
124 '/watch clear done remove só os concluídos',
125 '/watch clear demo remove só os de demo',
126 ].join('\n'),
127 failedToRun: what => `Não deu para rodar: ${what}`,
128 },
129 en: {
130 description: 'Subagents: /watch opens the pane; clear [done|demo], demo, help',
131 pane: 'Agents',
132 hint: "This session's subagents: tokens from each model request, and a toast when one stalls.",
133 counts: (r, f, t) => `${r} running · ${f} finished · ${t} tokens`,
134 stalled: n => `${n} stalled`,
135 lead: 'lead',
136 leadLine: (t, out) => `${t} tokens · out ${out}`,
137 empty: 'No subagents yet this session.',
138 emptyNext: '/watch demo shows what this looks like.',
139 otherLoops: t => `other loops (compaction, memory) ${t}`,
140 lastRun: 'Last run',
141 tools: (n, failed) => `${n} tool${n === 1 ? '' : 's'}${failed ? ` (${failed} failed)` : ''}`,
142 thinking: 'thinking',
143 starting: 'starting',
144 statuses: { completed: 'done' },
145 finished: (n, isOpen) => `${n} finished ${isOpen ? '▾' : '▸'}`,
146 clearDone: 'clear finished',
147 clearDemo: 'clear demo',
148 demoBadge: 'demo',
149 close: 'Close',
150 statusLine: (n, t, stalled, warn) => `◇ ${n} agent${n === 1 ? '' : 's'} · ${t}${stalled ? ` · ${warn} ${stalled} stalled` : ''}`,
151 stalledToast: (name, why) => `${name} looks stalled: ${why}. /watch`,
152 doneToast: run => `Agents done: ${run}. /watch`,
153 thinkingFor: span => `thinking for ${span}`,
154 doingFor: (doing, span) => `${doing} for ${span}`,
155 inATool: 'in a tool call',
156 quietFor: span => `quiet for ${span}`,
157 run: (count, t, span) => `${count} agent${count === 1 ? '' : 's'}, ${t} tokens in ${span}`,
158 heaviest: (label, t, share) => `. Heaviest: ${label} ${t} (${share}%)`,
159 windowUsed: p => `. Used ~${p}% of the 5h window`,
160 others: 'others',
161 shareTitle: 'Tokens by agent',
162 recentTitle: 'recent tools',
163 noRecent: 'no tool calls yet',
164 failedMark: 'failed',
165 investigate: 'investigate',
166 askInvestigate: (name, doing, span) => `The agent "${name}" looks stalled: ${doing}, for ${span}. Check what happened and tell me what to do.`,
167 sortStart: 'by start',
168 sortTokens: 'by tokens',
169 range: (from, to, of) => `${from}–${to} of ${of}`,
170 up: '▲ up',
171 down: '▼ down',
172 clearedDone: 'Finished agents cleared.',
173 clearedDemo: 'Demo agents cleared.',
174 clearedBoth: 'Finished and demo agents cleared.',
175 demoAdded: 'Three demo agents added; one is stalled. /watch clear demo removes them.',
176 answer: (r, f) => `${r} running, ${f} finished.`,
177 lastRunAnswer: run => `Last run: ${run}.`,
178 demoLabels: ['Map the API routes', 'Fix the flaky test', 'Check sources'],
179 help: [
180 '/watch open the pane: agent tree with tokens, tool calls and what each one is doing',
181 '/watch demo add three fake agents, one of them stalled',
182 '/watch clear drop finished and demo agents',
183 '/watch clear done drop finished agents only',
184 '/watch clear demo drop demo agents only',
185 ].join('\n'),
186 failedToRun: what => `Could not run: ${what}`,
187 },
188}
189types/index.d.ts 85 lines1/** Token counts as the API reports them, summed over a loop's model requests. */
2export type Tokens = {
3 /** Uncached input. */
4 input: number
5 output: number
6 cacheRead: number
7 cacheWrite: number
8}
9
10/** What a loop is doing since `lastAt`. */
11export type Activity = 'thinking' | 'tool' | 'idle'
12
13/** One subagent, fork, teammate or workflow agent. Times are epoch milliseconds. */
14export type Agent = {
15 id: string
16 /** The Agent call's description. */
17 label: string
18 /** `general-purpose`, `Explore`, `fork`, `teammate`, a plugin's type... */
19 type: string
20 /** What SendMessage addresses it by, when it has one. */
21 name?: string
22 parentId?: string
23 /** The model of its last request, else the one it was spawned on. */
24 model?: string
25 /** The engine's status: pending, running, waiting, idle, completed, failed, killed. */
26 status: string
27 startedAt: number
28 endedAt?: number
29 /** Last model request or tool call, started or ended. */
30 lastAt: number
31 activity: Activity
32 /** The tool in flight, or the last one, in plain words. */
33 doing?: string
34 tools: number
35 errors: number
36 steps: number
37 tokens: Tokens
38 /** True once the stall toast fired; activity re-arms it. */
39 isStalled: boolean
40 /** The last tool calls, oldest first: what each did, and whether it worked (null while it runs). */
41 recent?: { doing: string; ok: boolean | null }[]
42}
43
44/** What one wave of agents came to, from the first spawn to the last agent ending. */
45export type Run = {
46 endedAt: number
47 count: number
48 durationMs: number
49 total: number
50 /** The agent with the most tokens, and its share of the run's tokens (0 to 1). */
51 top: { label: string; total: number; share: number } | null
52 /** Points of the 5-hour plan window the wave used; null without readings. */
53 windowUsed?: number | null
54}
55
56declare module 'claude-code' {
57 interface PluginState {
58 'agent-watch': {
59 agents: Agent[]
60 /** Tokens of loops no agent listing names (compaction, memory), by loop id. */
61 orphans: Record<string, Tokens>
62 /** The main loop's tokens this session. */
63 lead: Tokens
64 /** Agents active at the last refresh. */
65 live: number
66 /** When the last wave ended; agents started after it belong to the next. */
67 standDownAt: number
68 lastRun: Run | null
69 /** Moves while agents run, so the pane's clocks and spinners redraw. */
70 tick: number
71 /** Whether the pane lists finished agents, or folds them into one line. */
72 showDone: boolean
73 /** Agents a clear removed: kept out though the listing still names them. */
74 dropped: string[]
75 /** Agent rows the pane shows opened, with their recent tool calls. */
76 expanded: string[]
77 sortBy: 'start' | 'tokens'
78 /** The first agent row the pane shows. */
79 offset: number
80 /** The 5-hour window's percent when the current wave started. */
81 waveStart: number | null
82 }
83 }
84}
85