A side panel listing dispatched agents and background shells, with purpose, tokens and duration

Claude Code mods: plugins that change Claude Code's own interface or behaviour through function hooks. This repository holds several mods, one folder each.
| Mod | What it does |
|---|---|
dispatch-panel | A side panel listing dispatched agents and background shells, with their purpose, input and output tokens and duration, in Active and Finished tabs, coloured by what each agent is doing. |
godmode (an agentic dev loop for any project) is being built in godmode/. It is not in the marketplace yet.
The repository is a marketplace (.claude-plugin/marketplace.json), so the mods install from GitHub with no clone.
1. Add the marketplace. At a Claude Code prompt:
/plugin marketplace add tinkybeatz/tinky-claude-mods
2. Install a mod. Still at the prompt:
/plugin install dispatch-panel@tinky-claude-mods
Pick the user scope so it loads in every project. The mod runs at once, with no restart.
Use the <mod>@tinky-claude-mods form. /plugin install dispatch-panel --marketplace … is ignored by some versions, which then search the default marketplace and answer Plugin "dispatch-panel" not found.
3. Use the fullscreen layout, so the panel docks on the right. Claude Code draws a panel on the right only in its fullscreen layout, in a window at least 110 columns wide. Otherwise the panel sits above the input bar. The layout is chosen when Claude Code starts, so a mod cannot change it. In a normal terminal, run once:
echo 'export CLAUDE_CODE_NO_FLICKER=1' >> ~/.zshrc
(~/.bashrc for bash.) Then open a new terminal window and start claude. Every new window keeps the setting. Inside tmux, Claude Code uses the other layout by default, so start it outside tmux. A window narrower than 110 columns puts the panel back above the input bar until it is widened.
4. Updating. An installed mod runs the copy made when it was installed. To get a newer version:
claude plugin update dispatch-panel@tinky-claude-mods
Then restart Claude Code (or run /reload-plugins in an open session). /plugin → Installed also has Update now.
| What you see | Why | Fix |
|---|---|---|
/plugin → Errors: Failed to load hooks from …/hooks.json … expected record, received undefined | This machine's Claude Code has mods switched off, so it reads hooks.json in the older hooks format | Run claude -p "Reply with the single word ok." --model haiku once so Claude Code refreshes its settings, then quit and restart it. If the error stays, the logged-in account does not have mods yet. claude update if the version is old. |
Plugin "dispatch-panel" not found in … marketplace | The marketplace was not added, or the --marketplace flag was ignored | Steps 1 and 2 above, with the @tinky-claude-mods form. |
| Adding the marketplace is refused | An organization's managed settings restrict marketplaces | An admin has to allow it. |
| The panel is above the input bar | Main-screen layout, tmux, or a narrow window | Step 3. |
| Some features never react (on a work login) | Where an organization manages Claude Code, some hooks of plugins a person installs are skipped | An admin lists the plugins in the managed settings. |
To run a mod straight from a clone, so every edit is picked up: add its folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json. Separate several folders with :.
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/Documents/GAEL/AI/tinky-claude-mods/dispatch-panel"
}
New sessions load the mod, and a session reloads it when its files change. A session that was already running when the setting changed does not pick it up.
For one session only:
claude --plugin-dir ~/Documents/GAEL/AI/tinky-claude-mods/dispatch-panel
Do not install the same mod from the marketplace and from a clone at once.
The panel opens by itself in a terminal at least 144 columns wide, docked on the right in the fullscreen layout (see step 3 above). Elsewhere, open it with /dispatches.
| Command | Effect |
|---|---|
/dispatches | Opens the panel. |
/dispatches active / finished | Opens the panel on that tab. |
/dispatches clear | Drops finished rows. |
/dispatches clear all | Drops every row, active ones included. |
/dispatches width <columns> | Sets the panel's width, remembered across sessions. |
While the panel has focus, a and f switch tabs.
In each tab, rows are grouped into an Agents section and a Shells section; click a section's header to collapse or expand it.
Only work the main thread started gets a card of its own. An agent or shell that a dispatched agent started is listed inside that agent's card as an indented line (├ and └, one step deeper per level), with its own status mark, activity tag ([shell] for a shell) and duration. The card stays in Active while anything in it still runs, and its token line adds sub in and out, the tokens of every agent listed inside it. An agent counts as finished once it hands back its result (the engine marks it idle), and returns to Active if a message wakes it again. A teammate that is idle is waiting for a message, not done, so it stays in Active with a ◌ mark.
Each row's title is its purpose (the description the agent or shell was given), followed by its activity tag and how long it has run. Below it, an agent's type appears only when it is not a catch-all one (general-purpose, claude), such as Explore or Plan. Agents also show what they have consumed: in, the input tokens of all their requests added up (cache reads and writes included, since each request sends the whole context again), and out, all the output tokens they have generated.
The panel's footer shows the account's usage limits as bars, one per window the last API response reported (Session (5h), Week, or a gateway's spend limit), each with the percentage used and the time until it resets. A bar is green, then yellow from 70% and red from 90%. Off a subscription, or before the first response, no window has a reading and the footer is hidden.
A running agent's card ends with a strip naming what it is doing right now, read from its latest tool call: reading register.tsx, running tests, committing, updating Jira, and thinking between calls. Shell commands are matched against a list of known ones (git, gh, test runners, linters, …); any other command shows the description the agent gave it.
Each agent's colour shows what it is doing, not its type: a general-purpose agent writing code and one updating Jira get different colours. When an agent starts, a small fast model reads its type, description and the start of its prompt, and picks one activity (one model call per agent). A description that names its activity outright ("Update PROD-4882 in Jira", "Open a PR for the fix") is coloured at once, with no model call, and the model cannot overrule it. The card shows that activity's short tag in brackets after the agent's type, such as general-purpose [research], so two agents share a colour only when they do the same kind of work. The activities, tags and colours are in dispatch-panel/hooks/kinds.ts.
| Activity | Tag | Colour |
|---|---|---|
| writing or changing code | code | blue |
| reviewing or testing | review | red |
| updating tickets (Jira, issues) | jira | cyan |
| git, branches or pull requests | git | pink |
| searching or researching | research | yellow |
| planning or designing | plan | green |
| writing docs or text | docs | white |
| configuring or setting up | setup | purple |
| analysing data | data | lime |
| something else | other | orange |
Shells, and agents not classified yet (the first second or so), are slate grey and have no tag.
A failed row keeps its activity's border and shows a red ✗.
A background shell's row ends when its completion notification arrives, with the status that notification reports (done, failed or killed), including shells an agent started.
Known limit: workflow agents are not listed.
Each mod is a folder with .claude-plugin/plugin.json, hooks/hooks.json, its hooks module and, when it keeps state, a types/index.d.ts contract. From a mod's folder:
claude plugin validate .
claude plugin test .
tsc -p .
tsc -p . needs the type declarations the engine lays in .claude-plugin/types/ when it loads the mod; they are generated, so they are not committed.
hooks/register.tsx 659 lines1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, Register } from 'claude-code'
3
4import type { Dispatch, DispatchKind, DispatchTab, UsageLimit } from '../types'
5import { THINKING, describeAction, shellTitle } from './actions'
6import {
7 ACTIVITY_LABELS,
8 CLASSIFIER_HINTS,
9 FAILED_MARK_COLOR,
10 activityColor,
11 activityFromDescription,
12 activityTag,
13} from './kinds'
14import { buildTree, descendantsOf, dropFinishedTrees, liveRootIds } from './tree'
15import type { Descendant } from './tree'
16
17const PANE = 'dispatches'
18const TITLE = 'Dispatched'
19const MAX_ROWS = 100
20const OUTER_X = 2
21const OUTER_Y = 1
22const INNER_X = 1
23const ROW_GAP = 0
24const ACTION_BACKGROUND = '#3a3a3a'
25const WIDTH_SHARE = 2 / 3
26const MIN_COLUMNS = 24
27const COLUMNS_KEY = 'columns'
28/** The footer's name for each rate-limit window; any other kind shows its own name. */
29const LIMIT_NAMES: Readonly<Record<string, string>> = { five_hour: 'Session (5h)', seven_day: 'Week', spend_limit: 'Spend' }
30const LIMIT_EMPTY_COLOR = '#3a3a3a'
31/** A bar turns yellow, then red, as its window fills. */
32const limitColor = (percent: number) => (percent >= 90 ? '#ef4444' : percent >= 70 ? '#facc15' : '#22c55e')
33/** Catch-all agent types: the card's subtitle names an agent's type only when it is not one of these. */
34const GENERIC_TYPES = new Set(['general-purpose', 'claude', 'agent'])
35
36const ENDED = new Set(['completed', 'failed', 'killed', 'done'])
37
38/**
39 * The panel's status for an agent the engine lists. A subagent that handed back its
40 * result is left `idle`: it could be woken again, but its work is done, so it counts as
41 * finished, and a message that wakes it turns it back to running. A teammate that is
42 * `idle` is waiting for a message, not done, so it keeps `idle` and stays in Active.
43 */
44const panelStatus = (agent: AgentInfo) => {
45 const isTeammate = agent.teammateId !== undefined || agent.type === 'teammate'
46 return agent.status === 'idle' && !isTeammate ? 'done' : agent.status
47}
48
49const dispatches = atom({ plugin: 'dispatch-panel', key: 'dispatches' } as const, [])
50const now = atom({ plugin: 'dispatch-panel', key: 'now' } as const, 0)
51const tab = atom({ plugin: 'dispatch-panel', key: 'tab' } as const, 'active' as DispatchTab)
52const isSized = atom({ plugin: 'dispatch-panel', key: 'isSized' } as const, false)
53const limits = atom({ plugin: 'dispatch-panel', key: 'limits' } as const, [] as UsageLimit[])
54const collapsed = atom({ plugin: 'dispatch-panel', key: 'collapsed' } as const, { agent: false, shell: false } as Record<DispatchKind, boolean>)
55
56/** The docked pane's width as last drawn; the render hook records it, since no `$` call reports it. */
57let measuredColumns: number | undefined
58
59const isRunning = (row: Dispatch) => !ENDED.has(row.status)
60
61/** A task-notification row's id and status, in the order the engine writes them. */
62const TASK_NOTIFICATION = /<task-id>([^<]+)<\/task-id>[\s\S]*?<status>([^<]+)<\/status>/g
63/** The panel's status for each status a task notification reports. */
64const NOTIFIED_STATUS: Readonly<Record<string, string>> = { completed: 'done', failed: 'failed', killed: 'killed' }
65
66const firstLine = (text: string) => text.split('\n')[0]?.trim() ?? ''
67
68const upsert = ($: EngineInterface, id: string, patch: (row: Dispatch | undefined) => Dispatch | undefined) =>
69 update($, dispatches, list => {
70 const rows = list ?? []
71 const found = rows.find(row => row.id === id)
72 const next = patch(found)
73 if (next === undefined) return rows
74 if (found === undefined) return [...rows, next].slice(-MAX_ROWS)
75 return rows.map(row => (row.id === id ? next : row))
76 })
77
78/** Ends each tracked row a task notification in `raw` reports on, with the status it reports. */
79const applyNotifications = async ($: EngineInterface, raw: string) => {
80 if (!raw.includes('<task-notification>')) return
81 const at = await $.clock.now()
82 for (const match of raw.matchAll(TASK_NOTIFICATION)) {
83 const [, id, status] = match
84 if (id === undefined || status === undefined) continue
85 const ended = NOTIFIED_STATUS[status] ?? 'done'
86 await upsert($, id, row =>
87 row === undefined || (!isRunning(row) && row.status !== 'done')
88 ? undefined
89 : { ...row, status: ended, endedAt: row.endedAt ?? at },
90 )
91 }
92}
93
94const formatCount = (count: number) => {
95 if (count < 1000) return `${count}`
96 if (count < 1_000_000) return `${(count / 1000).toFixed(1)}k`
97 return `${(count / 1_000_000).toFixed(2)}M`
98}
99
100const storedColumns = async ($: EngineInterface) => {
101 const stored = await $.store.get(COLUMNS_KEY)
102 return typeof stored === 'number' ? stored : undefined
103}
104
105const openPane = async ($: EngineInterface) => {
106 const columns = await storedColumns($)
107 return $.ui.open({ id: PANE, title: TITLE, ...(columns === undefined ? {} : { columns }) })
108}
109
110/**
111 * Narrows the dock once per session to the remembered width, or to two thirds of the
112 * width it was first drawn at, and remembers that width for later sessions.
113 * The dock treats it as a request: a width the person dragged the dock to wins.
114 */
115const fitWidth = async ($: EngineInterface) => {
116 if (measuredColumns === undefined || (await read($, isSized)) === true) return
117 const panes = await $.ui.panes()
118 if (!panes.some(pane => pane.id === PANE && pane.isPlaced)) return
119 const columns = (await storedColumns($)) ?? Math.max(MIN_COLUMNS, Math.round(measuredColumns * WIDTH_SHARE))
120 await $.store.set(COLUMNS_KEY, columns)
121 await update($, isSized, () => true)
122 await $.ui.open({ id: PANE, title: TITLE, columns })
123}
124
125const formatDuration = (ms: number) => {
126 const seconds = Math.max(0, Math.floor(ms / 1000))
127 const h = Math.floor(seconds / 3600)
128 const m = Math.floor((seconds % 3600) / 60)
129 const s = seconds % 60
130 if (h > 0) return `${h}h${String(m).padStart(2, '0')}m`
131 if (m > 0) return `${m}m${String(s).padStart(2, '0')}s`
132 return `${s}s`
133}
134
135/** Time left until an ISO timestamp, as `2h14m` or `3d04h`; empty when unknown or past. */
136const formatUntil = (iso: string | undefined, at: number) => {
137 const resets = iso === undefined ? Number.NaN : Date.parse(iso)
138 if (Number.isNaN(resets) || resets <= at) return ''
139 const minutes = Math.ceil((resets - at) / 60_000)
140 const d = Math.floor(minutes / 1440)
141 const h = Math.floor((minutes % 1440) / 60)
142 const m = minutes % 60
143 if (d > 0) return `${d}d${String(h).padStart(2, '0')}h`
144 if (h > 0) return `${h}h${String(m).padStart(2, '0')}m`
145 return `${m}m`
146}
147
148/**
149 * Reads the account's rate-limit windows, as the status line has them. The plain
150 * `$.session.usage()` call costs nothing; the atom is written only when a window moved.
151 */
152const syncLimits = async ($: EngineInterface) => {
153 const { rateLimits } = await $.session.usage()
154 const fresh = rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt }))
155 const known = (await read($, limits)) ?? []
156 if (JSON.stringify(fresh) !== JSON.stringify(known)) await update($, limits, () => fresh)
157}
158
159const statusMark = (status: string) => {
160 if (status === 'failed') return '✗'
161 if (status === 'killed') return '■'
162 if (status === 'idle' || status === 'waiting') return '◌'
163 if (ENDED.has(status)) return '✓'
164 return '●'
165}
166
167/** Reconciles tracked agents with the engine's own list, and finds any the spawn hook missed. */
168const syncAgents = async ($: EngineInterface, at: number) => {
169 const agents = await $.agent.list()
170 for (const agent of agents) {
171 const status = panelStatus(agent)
172 await upsert($, agent.id, row => {
173 if (row === undefined) {
174 if (ENDED.has(status)) return undefined
175 return {
176 id: agent.id,
177 kind: 'agent',
178 label: agent.type,
179 purpose: agent.description,
180 status,
181 startedAt: at,
182 parentId: agent.parentId,
183 ...(activityFromDescription(agent.description) === undefined
184 ? {}
185 : { activity: activityFromDescription(agent.description), isClassified: true }),
186 }
187 }
188 const parentId = row.parentId ?? agent.parentId
189 if (row.status === status && row.parentId === parentId) return undefined
190 const hasEnded = ENDED.has(status)
191 return {
192 ...row,
193 status,
194 parentId,
195 endedAt: hasEnded ? (row.endedAt ?? at) : undefined,
196 }
197 })
198 }
199 // An agent the engine no longer lists is over, whatever its row last said:
200 // without this, a row whose final status was never seen stays active forever.
201 const listed = new Set(agents.map(agent => agent.id))
202 await update($, dispatches, list =>
203 (list ?? []).map(row =>
204 row.kind === 'agent' && isRunning(row) && !listed.has(row.id) ? { ...row, status: 'done', endedAt: at } : row,
205 ),
206 )
207}
208
209/**
210 * Ends the running shells of an agent that has ended. Such a shell sends its completion
211 * notice to its owner's queue, which nobody reads once the owner is done or stopped, so
212 * without this its row stays active forever. A stopped owner's shells show as stopped.
213 */
214const endOrphanShells = async ($: EngineInterface, at: number) =>
215 update($, dispatches, list => {
216 const rows = list ?? []
217 const byId = new Map(rows.map(row => [row.id, row]))
218 return rows.map(row => {
219 const owner = row.parentId === undefined ? undefined : byId.get(row.parentId)
220 if (row.kind !== 'shell' || !isRunning(row) || owner === undefined || isRunning(owner)) return row
221 return { ...row, status: owner.status === 'killed' ? 'killed' : 'done', endedAt: owner.endedAt ?? at }
222 })
223 })
224
225/**
226 * Classifies one agent's activity per tick, outside any hook's dispatch, so a slow
227 * model call never holds up a spawn. A failed call settles on 'something else'.
228 */
229const classifyNext = async ($: EngineInterface) => {
230 const list = (await read($, dispatches)) ?? []
231 const row = list.find(one => one.kind === 'agent' && one.isClassified !== true)
232 if (row === undefined) return
233 await upsert($, row.id, found => (found === undefined ? undefined : { ...found, isClassified: true }))
234 const text = [`agent type: ${row.label}`, `task: ${row.purpose}`, row.brief ? `prompt: ${row.brief}` : '', CLASSIFIER_HINTS]
235 .filter(Boolean)
236 .join('\n')
237 let activity: string | undefined
238 try {
239 activity = await $.model.classify(text, ACTIVITY_LABELS)
240 } catch {
241 activity = undefined
242 }
243 await upsert($, row.id, found =>
244 found === undefined ? undefined : { ...found, activity: activity ?? 'something else' },
245 )
246}
247
248const tick = async ($: EngineInterface) => {
249 const at = await $.clock.now()
250 await syncAgents($, at)
251 await endOrphanShells($, at)
252 await classifyNext($)
253 await fitWidth($)
254 await syncLimits($).catch(() => undefined)
255 const list = (await read($, dispatches)) ?? []
256 if (list.some(isRunning)) await update($, now, () => at)
257}
258
259export const register: Register = on => {
260 on('session.start', async ($, e, next) => {
261 await $.command.register({
262 name: 'dispatches',
263 description:
264 'Show dispatched agents and shells in a side panel (`active`, `finished`, `clear` to drop finished rows, `clear all` to drop every row, `width <columns>` to resize)',
265 argumentHint: '[active|finished|clear|clear all|width <columns>]',
266 })
267 const startedAt = await $.clock.now()
268 await update($, now, () => startedAt)
269 $.clock.every(1000, () => void tick($))
270 void openPane($)
271
272 return next(e)
273 })
274
275 on('command.run', { command: 'dispatches' }, async ($, e) => {
276 const arg = e.args.trim()
277 if (arg === 'active' || arg === 'finished') {
278 await update($, tab, () => arg)
279 await openPane($)
280 return { text: `Dispatch panel opened on ${arg}.` }
281 }
282 const width = /^width\s+(\d+)$/.exec(arg)
283 if (width?.[1] !== undefined) {
284 const columns = Math.max(MIN_COLUMNS, Number(width[1]))
285 await $.store.set(COLUMNS_KEY, columns)
286 await update($, isSized, () => true)
287 await $.ui.open({ id: PANE, title: TITLE, columns })
288 return { text: `Dispatch panel width set to ${columns} columns.` }
289 }
290 if (arg === 'clear all') {
291 await update($, dispatches, () => [])
292 return { text: 'Cleared every dispatch, active ones included.' }
293 }
294 if (arg === 'clear') {
295 await update($, dispatches, list => dropFinishedTrees(list ?? [], isRunning))
296 return { text: 'Cleared finished dispatches.' }
297 }
298 await openPane($)
299 return { text: 'Dispatch panel opened.' }
300 })
301
302 on('agent.spawn', async ($, e, next) => {
303 const startedAt = await $.clock.now()
304 const spawned = await next(e)
305 if (spawned.agentId !== undefined) {
306 const agentId = spawned.agentId
307 // A description that names its activity outright colours the row at once.
308 const signalled = activityFromDescription(e.description)
309 await upsert($, agentId, row => ({
310 id: agentId,
311 kind: 'agent',
312 label: e.subagentType || 'agent',
313 purpose: e.description || firstLine(e.prompt),
314 brief: e.prompt.slice(0, 600),
315 activity: row?.activity ?? signalled,
316 isClassified: row?.isClassified ?? (signalled === undefined ? undefined : true),
317 action: row?.action ?? 'starting',
318 status: row?.status ?? 'running',
319 startedAt: row?.startedAt ?? startedAt,
320 endedAt: row?.endedAt,
321 inputTokens: row?.inputTokens,
322 outputTokens: row?.outputTokens,
323 parentId: e.parentAgentId ?? row?.parentId,
324 }))
325 }
326 return spawned
327 })
328
329 on('turn.step', async function* ($, e, next) {
330 const step = yield* next(e)
331 const agentId = e.agentId
332 const usage = step.usage
333 if (agentId !== undefined && usage !== null) {
334 const inputTokens = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
335 await upsert($, agentId, row =>
336 row === undefined
337 ? undefined
338 : {
339 ...row,
340 inputTokens: (row.inputTokens ?? 0) + inputTokens,
341 outputTokens: (row.outputTokens ?? 0) + usage.output_tokens,
342 },
343 )
344 }
345 return step
346 })
347
348 /** Each tool call an agent makes is its current action, until the call ends and it thinks again. */
349 on('tool.call', async ($, e, next) => {
350 const agentId = e.agentId
351 if (agentId === undefined) return next(e)
352 const action = describeAction(String(e.tool), e as unknown as Record<string, unknown>)
353 const setAction = (label: string) =>
354 upsert($, agentId, row => (row === undefined || !isRunning(row) ? undefined : { ...row, action: label }))
355 await setAction(action)
356 const ran = await next(e)
357 await setAction(THINKING)
358 return ran
359 })
360
361 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
362 if (e.tool !== 'Bash') return next(e)
363 const startedAt = await $.clock.now()
364 const ran = await next(e)
365 if (ran.deny !== undefined || ran.isError) return ran
366 const taskId = ran.result.backgroundTaskId
367 if (taskId !== undefined) {
368 const purpose = e.description || shellTitle(e.command)
369 await upsert($, taskId, row =>
370 row ?? { id: taskId, kind: 'shell', label: 'bash', purpose, status: 'running', startedAt, parentId: e.agentId },
371 )
372 }
373 return ran
374 })
375
376 on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
377 if (e.tool !== 'TaskStop') return next(e)
378 const ran = await next(e)
379 const taskId = e.task_id ?? e.shell_id
380 if (taskId !== undefined && ran.deny === undefined && !ran.isError) {
381 const at = await $.clock.now()
382 await upsert($, taskId, row =>
383 row === undefined || !isRunning(row) ? undefined : { ...row, status: 'killed', endedAt: at },
384 )
385 }
386 return ran
387 })
388
389 /**
390 * Every finished background task sends a notification to its owner, a subagent
391 * included, carrying the task's id and final status. It is the one signal that
392 * reaches the panel for a shell a subagent started.
393 *
394 * It reaches the session twice: `session.receive` when it is queued, the moment
395 * the task ends, and `session.append` once it becomes a row of the conversation.
396 * Between the person's turns the main thread's notifications wait in the queue
397 * until the next turn, and some are folded into that turn without a row of their
398 * own, so the queue is where a shell that ends between turns is seen.
399 */
400 on('session.receive', async ($, e, next) => {
401 await applyNotifications($, e.text)
402 return next(e)
403 })
404
405 on('session.append', async ($, e, next) => {
406 const appended = await next(e)
407 await applyNotifications($, JSON.stringify(e.message.content))
408 return appended
409 })
410
411 /** At each stop the engine lists the background work still in flight: any shell missing from it has finished. */
412 on('classic.Stop', async ($, e, next) => {
413 const inFlight = e.background_tasks ?? []
414 const at = await $.clock.now()
415 const liveShells = new Set(inFlight.filter(task => task.type === 'shell').map(task => task.id))
416 await update($, dispatches, list =>
417 (list ?? []).map(row =>
418 row.kind === 'shell' && isRunning(row) && !liveShells.has(row.id)
419 ? { ...row, status: 'done', endedAt: at }
420 : row,
421 ),
422 )
423 for (const task of inFlight) {
424 if (task.type !== 'shell') continue
425 await upsert($, task.id, row =>
426 row ?? {
427 id: task.id,
428 kind: 'shell',
429 label: 'bash',
430 purpose: task.description || shellTitle(task.command ?? ''),
431 status: 'running',
432 startedAt: at,
433 },
434 )
435 }
436 return next(e)
437 })
438
439 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
440 const { Box, Button, Text } = $.ui.resolve(e)
441 const list = (await read($, dispatches)) ?? []
442 const at = (await read($, now)) ?? 0
443 const shownTab = (await read($, tab)) ?? 'active'
444 const width = Math.max(10, e.props.bodyColumns)
445 // The card's inside: the panel's width less its side padding, the card's border and its padding.
446 const stripColumns = Math.max(4, width - 2 * OUTER_X - 2 - 2 * INNER_X)
447 if (e.props.placement === 'dock') measuredColumns = e.props.bodyColumns
448 // One card per top-level row; what a dispatched agent started is listed inside its card,
449 // and the whole tree stays in Active while any row in it runs.
450 const tree = buildTree(list)
451 const live = liveRootIds(list, isRunning)
452 const roots = list.filter(row => tree.rootIdOf(row) === row.id)
453 const active = roots.filter(row => live.has(row.id))
454 const finished = roots.filter(row => !live.has(row.id)).reverse()
455 const shown = shownTab === 'active' ? active : finished
456 const showTab = (next: DispatchTab) => () => void update($, tab, () => next)
457
458 // Terminal spacing is in cells, not pixels: a cell is about 10px wide and 20px tall,
459 // so OUTER (about 20px) is 2 columns or 1 row, and INNER (about 10px) is 1 column.
460 // Half a row does not exist, so the gap between rows (about 10px asked) is 0 rows.
461 const elapsedOf = (row: Dispatch) => (row.endedAt ?? Math.max(at, row.startedAt)) - row.startedAt
462 const markColorOf = (row: Dispatch) => (row.status === 'failed' ? FAILED_MARK_COLOR : activityColor(row))
463
464 /** One line inside a card for an agent or shell the card's agent started, indented by depth. */
465 const childLine = ({ row, depth, isLast }: Descendant) => {
466 const running = isRunning(row)
467 const color = activityColor(row)
468 const tag = row.kind === 'shell' ? 'shell' : activityTag(row)
469 return (
470 <Box key={row.id} flexDirection="row" gap={1}>
471 {/* Branch, mark and title in one text, so their spacing never collapses as the title truncates. */}
472 <Box flexShrink={1}>
473 <Text wrap="truncate-end">
474 <Text dimColor>{`${' '.repeat(depth)}${isLast ? '└' : '├'} `}</Text>
475 <Text color={markColorOf(row)} dimColor={!running}>
476 {statusMark(row.status)}
477 </Text>
478 <Text color={color} dimColor={!running}>{` ${row.purpose || row.label}`}</Text>
479 </Text>
480 </Box>
481 {tag !== undefined && (
482 <Box flexShrink={0}>
483 <Text color={color} dimColor>{`[${tag}]`}</Text>
484 </Box>
485 )}
486 <Box flexGrow={1} />
487 <Box flexShrink={0}>
488 <Text dimColor={!running}>{formatDuration(elapsedOf(row))}</Text>
489 </Box>
490 </Box>
491 )
492 }
493
494 const card = (row: Dispatch) => {
495 const running = isRunning(row)
496 const isLive = live.has(row.id)
497 const elapsed = elapsedOf(row)
498 const color = activityColor(row)
499 const tag = activityTag(row)
500 const markColor = markColorOf(row)
501 const descendants = descendantsOf(tree, row.id)
502 const subInput = descendants.reduce((sum, { row: child }) => sum + (child.inputTokens ?? 0), 0)
503 const subOutput = descendants.reduce((sum, { row: child }) => sum + (child.outputTokens ?? 0), 0)
504 return (
505 <Box
506 key={row.id}
507 flexDirection="column"
508 borderStyle="round"
509 borderColor={color}
510 borderDimColor={!isLive}
511 paddingX={INNER_X}
512 marginBottom={ROW_GAP}
513 >
514 <Box flexDirection="row" gap={1}>
515 <Text color={markColor} dimColor={!running}>
516 {statusMark(row.status)}
517 </Text>
518 <Box flexShrink={1}>
519 <Text color={color} bold={running} dimColor={!running} wrap="truncate-end">
520 {row.purpose || row.label}
521 </Text>
522 </Box>
523 {tag !== undefined && (
524 <Box flexShrink={0}>
525 <Text color={color} dimColor={!running}>{`[${tag}]`}</Text>
526 </Box>
527 )}
528 <Box flexGrow={1} />
529 <Box flexShrink={0}>
530 <Text dimColor={!running}>{formatDuration(elapsed)}</Text>
531 </Box>
532 </Box>
533 {row.kind === 'agent' && !GENERIC_TYPES.has(row.label) && (
534 <Text dimColor italic wrap="truncate-end">
535 {row.label}
536 </Text>
537 )}
538 {row.kind === 'agent' && (
539 <Text dimColor wrap="truncate-end">
540 {`in ${formatCount(row.inputTokens ?? 0)} · out ${formatCount(row.outputTokens ?? 0)}`}
541 {subInput + subOutput > 0 ? ` · sub in ${formatCount(subInput)} · out ${formatCount(subOutput)}` : ''}
542 </Text>
543 )}
544 {descendants.map(childLine)}
545 {row.kind === 'agent' && running && row.action !== undefined && (
546 // A row cannot be split, so the strip's top and bottom edges are half blocks:
547 // they read as half a row of space above and below a slightly taller strip.
548 <Box flexDirection="column" width={stripColumns}>
549 <Text color={ACTION_BACKGROUND}>{'▄'.repeat(stripColumns)}</Text>
550 <Box backgroundColor={ACTION_BACKGROUND} paddingX={INNER_X}>
551 <Text wrap="truncate-end">
552 <Text color={color}>▸ </Text>
553 {row.action}
554 </Text>
555 </Box>
556 <Text color={ACTION_BACKGROUND}>{'▀'.repeat(stripColumns)}</Text>
557 </Box>
558 )}
559 </Box>
560 )
561 }
562
563 const folded = (await read($, collapsed)) ?? { agent: false, shell: false }
564 const sections = (
565 [
566 { kind: 'agent', title: 'Agents', rows: shown.filter(row => row.kind === 'agent') },
567 { kind: 'shell', title: 'Shells', rows: shown.filter(row => row.kind === 'shell') },
568 ] as const
569 ).filter(section => section.rows.length > 0)
570 const toggle = (kind: DispatchKind) => () =>
571 void update($, collapsed, state => {
572 const current = state ?? { agent: false, shell: false }
573 return { ...current, [kind]: !current[kind] }
574 })
575
576 // The footer: one bar per rate-limit window of the account, pinned to the pane's bottom
577 // while the rows fit, and following the rows when they scroll.
578 const windows = (await read($, limits)) ?? []
579 const footerColumns = Math.max(4, width - 2 * OUTER_X)
580 const limitBar = (limit: UsageLimit) => {
581 const percent = Math.max(0, Math.min(100, limit.percentUsed))
582 const filled = Math.round((percent / 100) * footerColumns)
583 const resets = formatUntil(limit.resetsAt, at)
584 return (
585 <Box key={limit.kind} flexDirection="column">
586 <Box flexDirection="row" gap={1}>
587 <Box flexShrink={1}>
588 <Text dimColor wrap="truncate-end">
589 {LIMIT_NAMES[limit.kind] ?? limit.kind.replace(/_/g, ' ')}
590 </Text>
591 </Box>
592 <Box flexGrow={1} />
593 <Text color={limitColor(percent)}>{`${Math.round(limit.percentUsed)}%`}</Text>
594 {resets !== '' && <Text dimColor>{`· resets in ${resets}`}</Text>}
595 </Box>
596 <Text>
597 <Text color={limitColor(percent)}>{'█'.repeat(filled)}</Text>
598 <Text color={LIMIT_EMPTY_COLOR}>{'█'.repeat(footerColumns - filled)}</Text>
599 </Text>
600 </Box>
601 )
602 }
603
604 return (
605 <Box
606 flexDirection="column"
607 width={width}
608 minHeight={e.props.scroll.bodyRows}
609 paddingX={OUTER_X}
610 paddingY={OUTER_Y}
611 >
612 <Box flexDirection="row" gap={OUTER_X} marginBottom={OUTER_Y}>
613 <Button
614 key="tab-active"
615 hotkey="a"
616 variant={shownTab === 'active' ? 'primary' : 'secondary'}
617 onPress={showTab('active')}
618 >
619 Active ({active.length})
620 </Button>
621 <Button
622 key="tab-finished"
623 hotkey="f"
624 variant={shownTab === 'finished' ? 'primary' : 'secondary'}
625 onPress={showTab('finished')}
626 >
627 Finished ({finished.length})
628 </Button>
629 {shownTab === 'finished' && finished.length > 0 && (
630 <Button key="clear" plain dimColor onPress={() => void update($, dispatches, rows => dropFinishedTrees(rows ?? [], isRunning))}>
631 clear
632 </Button>
633 )}
634 </Box>
635 {shown.length === 0 && (
636 <Text dimColor>{shownTab === 'active' ? 'Nothing running.' : 'Nothing finished yet.'}</Text>
637 )}
638 {sections.map(section => (
639 <Box key={section.title} flexDirection="column" marginBottom={OUTER_Y}>
640 <Button key={`section-${section.kind}`} plain onPress={toggle(section.kind)}>
641 <Text dimColor bold>
642 {folded[section.kind] ? '▸' : '▾'} {section.title} ({section.rows.length})
643 </Text>
644 </Button>
645 {!folded[section.kind] && section.rows.map(card)}
646 </Box>
647 ))}
648 {windows.length > 0 && <Box flexGrow={1} />}
649 {windows.length > 0 && (
650 <Box flexDirection="column">
651 <Text dimColor>{'─'.repeat(footerColumns)}</Text>
652 {windows.map(limitBar)}
653 </Box>
654 )}
655 </Box>
656 )
657 })
658}
659hooks/actions.ts 124 lines1/**
2 * Turns one tool call into a short phrase of what the agent is doing right now
3 * ("reading register.tsx", "committing", "running tests"). Pure and synchronous:
4 * it reads only the call's own input, so it costs nothing per call.
5 */
6
7const MAX_DETAIL = 28
8
9const clip = (text: string, max = MAX_DETAIL) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
10
11const baseName = (path: unknown) => (typeof path === 'string' ? (path.split('/').pop() ?? path) : '')
12
13const field = (input: Record<string, unknown>, name: string) => {
14 const value = input[name]
15 return typeof value === 'string' ? value : ''
16}
17
18/** Recognised shell commands, checked in order; the first match names the action. */
19const SHELL_ACTIONS: ReadonlyArray<readonly [RegExp, string]> = [
20 [/\bgit\s+commit\b/, 'committing'],
21 [/\bgit\s+push\b/, 'pushing'],
22 [/\bgit\s+(pull|fetch)\b/, 'pulling'],
23 [/\bgit\s+rebase\b/, 'rebasing'],
24 [/\bgit\s+merge\b/, 'merging'],
25 [/\bgit\s+(checkout|switch|branch|worktree)\b/, 'switching branches'],
26 [/\bgit\s+(add|stash|reset|restore)\b/, 'staging changes'],
27 [/\bgit\s+(diff|status|log|show|blame)\b/, 'checking changes'],
28 [/\bgh\s+pr\s+create\b/, 'opening a PR'],
29 [/\bgh\s+pr\s+(comment|review)\b/, 'commenting on a PR'],
30 [/\bgh\s+pr\s+(merge|ready)\b/, 'merging a PR'],
31 [/\bgh\s+(pr|run)\s+(checks|watch|view)\b/, 'checking CI'],
32 [/\bgh\s+pr\b/, 'checking a PR'],
33 [/\b(vitest|jest|playwright|pytest)\b|\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b/, 'running tests'],
34 [/\btsc\b|\btypecheck\b/, 'type-checking'],
35 [/\b(eslint|oxlint|biome|lint)\b/, 'linting'],
36 [/\b(prettier|oxfmt)\b|\bformat\b/, 'formatting'],
37 [/\b(npm|pnpm|yarn|bun)\s+(install|i|add)\b/, 'installing dependencies'],
38 [/\bbuild\b/, 'building'],
39 [/\b(curl|wget)\b/, 'fetching a URL'],
40 [/\b(rg|grep|find|fd)\b/, 'searching files'],
41 [/\b(cat|head|tail|less|sed\s+-n)\b/, 'reading files'],
42]
43
44const shellAction = (input: Record<string, unknown>) => {
45 const command = field(input, 'command')
46 for (const [pattern, action] of SHELL_ACTIONS) if (pattern.test(command)) return action
47 const description = field(input, 'description')
48 if (description) return clip(description.charAt(0).toLowerCase() + description.slice(1), 36)
49 const program = command.trim().split(/\s+/)[0] ?? ''
50 return program ? `running ${clip(baseName(program))}` : 'running a command'
51}
52
53/** What an MCP tool call does, from its server and tool names. */
54const mcpAction = (tool: string) => {
55 const [, server = '', name = ''] = tool.split('__')
56 const isWrite = /create|update|edit|add|transition|comment|post|send|write|delete|move/i.test(name)
57 const service = /jira|atlassian/i.test(server + name)
58 ? 'Jira'
59 : /slack/i.test(server)
60 ? 'Slack'
61 : /notion/i.test(server)
62 ? 'Notion'
63 : /figma/i.test(server)
64 ? 'Figma'
65 : /drive|docs|sheets|gmail|calendar/i.test(server)
66 ? 'Google'
67 : server.replace(/^claude_ai_/, '').replace(/_/g, ' ')
68 return isWrite ? `updating ${service}` : `reading ${service}`
69}
70
71export const describeAction = (tool: string, input: Record<string, unknown>): string => {
72 if (tool.startsWith('mcp__')) return mcpAction(tool)
73 switch (tool) {
74 case 'Bash':
75 return shellAction(input)
76 case 'Read':
77 return `reading ${clip(baseName(input.file_path))}`
78 case 'Edit':
79 case 'MultiEdit':
80 return `editing ${clip(baseName(input.file_path))}`
81 case 'Write':
82 return `writing ${clip(baseName(input.file_path))}`
83 case 'NotebookEdit':
84 return `editing ${clip(baseName(input.notebook_path))}`
85 case 'Grep':
86 return `searching for "${clip(field(input, 'pattern'), 20)}"`
87 case 'Glob':
88 return 'finding files'
89 case 'WebFetch': {
90 const url = field(input, 'url')
91 const host = /^https?:\/\/([^/]+)/.exec(url)?.[1] ?? 'a page'
92 return `reading ${clip(host)}`
93 }
94 case 'WebSearch':
95 return 'searching the web'
96 case 'Agent':
97 return 'dispatching an agent'
98 case 'Skill':
99 return `using skill ${clip(field(input, 'skill'))}`
100 case 'TodoWrite':
101 case 'TaskCreate':
102 case 'TaskUpdate':
103 return 'updating its tasks'
104 case 'ToolSearch':
105 return 'loading tools'
106 default:
107 return `using ${clip(tool)}`
108 }
109}
110
111/** Shown between tool calls, while the agent's model is writing its next step. */
112export const THINKING = 'thinking'
113
114/**
115 * A background shell's title when its call gave no description: the action its command
116 * is recognised as ("checking a PR", "running tests"), else the command's first line.
117 * A raw loop such as `until [ "$(gh pr view 1233 ...` reads as noise in a card.
118 */
119export const shellTitle = (command: string) => {
120 const action = shellAction({ command })
121 if (!action.startsWith('running ') || action === 'running tests') return action.charAt(0).toUpperCase() + action.slice(1)
122 return command.split('\n')[0]?.trim() ?? ''
123}
124hooks/kinds.ts 59 lines1import type { Dispatch } from '../types'
2
3/**
4 * What an agent is doing, each with its own colour. The colour follows the activity,
5 * not the agent type: a `general-purpose` agent writing code and one updating Jira get
6 * different colours. A small model picks the activity when the agent starts. The tag is
7 * the short name shown in brackets after the agent's type.
8 */
9export const ACTIVITIES = [
10 { label: 'writing or changing code', tag: 'code', color: '#3b82f6' },
11 { label: 'reviewing or testing', tag: 'review', color: '#ef4444' },
12 { label: 'updating tickets (Jira, issues)', tag: 'jira', color: '#22d3ee' },
13 { label: 'git, branches or pull requests', tag: 'git', color: '#ec4899' },
14 { label: 'searching or researching', tag: 'research', color: '#facc15' },
15 { label: 'planning or designing', tag: 'plan', color: '#22c55e' },
16 { label: 'writing docs or text', tag: 'docs', color: '#e5e5e5' },
17 { label: 'configuring or setting up', tag: 'setup', color: '#a855f7' },
18 { label: 'analysing data', tag: 'data', color: '#a3e635' },
19 { label: 'something else', tag: 'other', color: '#f97316' },
20] as const
21
22export const ACTIVITY_LABELS = ACTIVITIES.map(activity => activity.label)
23
24/** Shells, and agents whose activity is not known yet. */
25export const NEUTRAL_COLOR = '#94a3b8'
26
27/** The colour of a failed row's ✗ mark; its border keeps its activity's colour. */
28export const FAILED_MARK_COLOR = '#ef4444'
29
30export const activityColor = (row: Dispatch) =>
31 ACTIVITIES.find(activity => activity.label === row.activity)?.color ?? NEUTRAL_COLOR
32
33/** The short activity tag shown after the agent's type, once the classifier has picked one. */
34export const activityTag = (row: Dispatch) => ACTIVITIES.find(activity => activity.label === row.activity)?.tag
35
36/**
37 * Words in an agent's description that name its activity outright. They settle it the
38 * moment the agent starts, before any model call, and the classifier cannot overrule
39 * them. Only unambiguous signals belong here: a ticket key alone does not, since dev
40 * agents carry one too ("Build PROD-4888").
41 */
42const CLEAR_SIGNALS: ReadonlyArray<readonly [RegExp, (typeof ACTIVITIES)[number]['label']]> = [
43 [/\b(jira|tickets?|backlog|sprint)\b/i, 'updating tickets (Jira, issues)'],
44 [/\b(commit|push|rebase|cherry-pick)\b|\bpull requests?\b|\b(open|create|merge|arm)\b.*\bPRs?\b/i, 'git, branches or pull requests'],
45]
46
47/** The activity a description names outright, or undefined when the classifier has to decide. */
48export const activityFromDescription = (description: string) =>
49 CLEAR_SIGNALS.find(([pattern]) => pattern.test(description))?.[1]
50
51/**
52 * Notes for the classifier on the confusions it made in use: writing a ticket's text is
53 * still updating tickets, not writing docs.
54 */
55export const CLASSIFIER_HINTS = [
56 'Creating, editing, commenting on or moving Jira issues or tickets is "updating tickets (Jira, issues)", even when the work is writing text.',
57 '"writing docs or text" is documentation, READMEs, notes and pages, not tickets.',
58].join('\n')
59hooks/tree.ts 60 lines1import type { Dispatch } from '../types'
2
3/**
4 * Groups rows by who started them. An agent or shell that a dispatched agent started
5 * has that agent as `parentId`; the panel draws one card per top-level row and lists
6 * the rest inside it. A row whose parent is not tracked (cleared, or never seen) is
7 * top-level itself.
8 */
9export type Tree = {
10 /** Each row's top-level ancestor's id. */
11 rootIdOf: (row: Dispatch) => string
12 /** The rows a row started, in the order they were tracked. */
13 childrenOf: (id: string) => Dispatch[]
14}
15
16export const buildTree = (rows: readonly Dispatch[]): Tree => {
17 const byId = new Map(rows.map(row => [row.id, row]))
18 const children = new Map<string, Dispatch[]>()
19 for (const row of rows) {
20 if (row.parentId === undefined || !byId.has(row.parentId)) continue
21 children.set(row.parentId, [...(children.get(row.parentId) ?? []), row])
22 }
23 const rootIdOf = (row: Dispatch) => {
24 let current = row
25 const seen = new Set<string>()
26 while (current.parentId !== undefined && !seen.has(current.id)) {
27 seen.add(current.id)
28 const parent = byId.get(current.parentId)
29 if (parent === undefined) break
30 current = parent
31 }
32 return current.id
33 }
34 return { rootIdOf, childrenOf: id => children.get(id) ?? [] }
35}
36
37/** The ids of the top-level rows whose tree still has a row running. */
38export const liveRootIds = (rows: readonly Dispatch[], isRunning: (row: Dispatch) => boolean) => {
39 const tree = buildTree(rows)
40 return new Set(rows.filter(isRunning).map(tree.rootIdOf))
41}
42
43/** Drops the rows of every tree with nothing left running; a finished child of a live tree stays. */
44export const dropFinishedTrees = (rows: readonly Dispatch[], isRunning: (row: Dispatch) => boolean) => {
45 const tree = buildTree(rows)
46 const live = liveRootIds(rows, isRunning)
47 return rows.filter(row => live.has(tree.rootIdOf(row)))
48}
49
50export type Descendant = { row: Dispatch; depth: number; isLast: boolean }
51
52/** A row's descendants, depth first, each with its depth below the row and whether it is its parent's last child. */
53export const descendantsOf = (tree: Tree, id: string, depth = 0): Descendant[] => {
54 const children = tree.childrenOf(id)
55 return children.flatMap((row, index) => [
56 { row, depth, isLast: index === children.length - 1 },
57 ...descendantsOf(tree, row.id, depth + 1),
58 ])
59}
60types/index.d.ts 55 lines1export type DispatchKind = 'agent' | 'shell'
2
3export type DispatchTab = 'active' | 'finished'
4
5export type Dispatch = {
6 id: string
7 kind: DispatchKind
8 /** Agent type (`Explore`, `general-purpose`) or `bash`. */
9 label: string
10 /** The Agent call's description, or the Bash call's description or command. */
11 purpose: string
12 status: string
13 startedAt: number
14 endedAt?: number
15 /** Input tokens the agent has sent across all its requests, cache reads and writes included; absent for shells. */
16 inputTokens?: number
17 /** Output tokens the agent has generated across all its requests; absent for shells. */
18 outputTokens?: number
19 /** The start of the agent's prompt, kept so its activity can be classified. */
20 brief?: string
21 /** What the agent is doing, one of the activity labels; absent until classified. */
22 activity?: string
23 /** True once classification was tried, so a failed attempt is not repeated. */
24 isClassified?: boolean
25 /** What a running agent is doing right now ("reading register.tsx", "committing"). */
26 action?: string
27 /** The agent that started this agent or shell; absent when the main thread did. */
28 parentId?: string
29}
30
31/** One of the account's rate-limit windows, as the last API response reported it. */
32export type UsageLimit = {
33 /** `five_hour`, `seven_day`, or a gateway's `spend_limit`. */
34 kind: string
35 /** How much of the window is used, 0 to 100. */
36 percentUsed: number
37 /** When the window resets, as an ISO 8601 timestamp. */
38 resetsAt?: string
39}
40
41declare module 'claude-code' {
42 interface PluginState {
43 'dispatch-panel': {
44 dispatches: Dispatch[]
45 now: number
46 tab: DispatchTab
47 isSized: boolean
48 /** Which sections the person collapsed, by kind. */
49 collapsed: Record<DispatchKind, boolean>
50 /** The account's rate-limit windows, drawn as bars in the footer; empty off a subscription. */
51 limits: UsageLimit[]
52 }
53 }
54}
55