A purple task bar above the prompt that fills as Claude ticks off its to-do list, and a side panel with one live card per subagent (step, tokens, time). No AI…

A Claude Code mod that shows what Claude and its subagents are doing, without a single model call.
/helper-desk opens a side panel with one live card per subagent: its step, model, tokens and time.It only reads what Claude Code already reports, so it adds no cost.
/plugin install helper-desk --marketplace FCisco95/claude-code-mods
Or copy plugins/helper-desk to ~/.claude/skills/helper-desk (see the repo README).
hooks/register.tsx 435 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Card, Todo } from '../types'
5
6const PANE = 'helper-desk'
7const PURPLE = '#7c3aed'
8const GREEN = '#16a34a'
9const AMBER = '#ca8a04'
10const ORANGE = '#c2410c'
11const COLORS = ['#2563eb', '#db2777', '#ea580c', '#16a34a', '#dc2626', '#0891b2', '#ca8a04', '#9333ea']
12const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
13// Rows one card takes: two border rows, a header, a model line, a step line and a bar.
14const CARD_ROWS = 6
15// How often the clocks and spinners redraw while something runs.
16const TICK_MS = 500
17
18// Change a tag when the fields of its value change: it declines what an earlier version stored.
19const todos = atom({ plugin: 'helper-desk', key: 'todos' } as const, [], { shape: 'todos-2' })
20const cards = atom({ plugin: 'helper-desk', key: 'cards' } as const, [], { shape: 'cards-2' })
21const currentTask = atom({ plugin: 'helper-desk', key: 'currentTask' } as const, '')
22const isBandHidden = atom({ plugin: 'helper-desk', key: 'isBandHidden' } as const, false)
23
24const formatTokens = (n: number) =>
25 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
26
27const formatTime = (ms: number) => {
28 const total = Math.max(0, Math.floor(ms / 1000))
29
30 return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
31}
32
33const shorten = (text: string, max: number) => {
34 const one = text.replace(/\s+/g, ' ').trim()
35
36 return one.length > max ? `${one.slice(0, max - 1)}…` : one
37}
38
39const shortModel = (model: string) => model.replace(/^claude-/, '')
40
41const spinnerAt = (now: number) => SPINNER[Math.floor(now / TICK_MS) % SPINNER.length]
42
43// A bar of `width` cells: full when finished, a block sweeping across while it runs (no total is known).
44const barOf = (width: number, now: number, isRunning: boolean) => {
45 if (!isRunning) {
46 return '█'.repeat(width)
47 }
48
49 const block = Math.min(4, Math.max(2, Math.floor(width / 6)))
50 const span = Math.max(1, width - block)
51 const at = Math.floor(now / TICK_MS) % (span * 2)
52 const pos = at < span ? at : span * 2 - at
53
54 return '░'.repeat(pos) + '█'.repeat(block) + '░'.repeat(width - block - pos)
55}
56
57// What a tool call is aimed at, for the card's one-line note.
58const aim = (input: Record<string, unknown>) => {
59 const raw = input.file_path ?? input.path ?? input.command ?? input.pattern ?? input.query ?? input.url ?? input.description
60
61 return typeof raw === 'string' ? shorten(raw, 60) : ''
62}
63
64const toTodos = (raw: unknown): Todo[] =>
65 Array.isArray(raw)
66 ? raw
67 .filter(t => typeof t?.content === 'string')
68 .map(t => ({
69 id: t.content as string,
70 content: t.content as string,
71 active: typeof t.activeForm === 'string' ? t.activeForm : (t.content as string),
72 status: t.status === 'completed' || t.status === 'in_progress' ? t.status : 'pending',
73 }))
74 : []
75
76// Sets the task list, stamps when a task starts running, and, when a different task
77// starts, drops the finished agents of the earlier ones (running agents and the new
78// task's own stay).
79const applyTodos = async ($: EngineInterface, incoming: Todo[]) => {
80 const now = await $.clock.now()
81 const before = await read($, todos)
82 const wasRunning = new Set(before.filter(t => t.status === 'in_progress').map(t => t.id))
83 const next = incoming.map((t): Todo => {
84 const old = before.find(o => o.id === t.id)
85
86 return t.status === 'in_progress'
87 ? { ...t, startedAt: wasRunning.has(t.id) ? (old?.startedAt ?? now) : now }
88 : { ...t, startedAt: old?.startedAt }
89 })
90 await update($, todos, () => next)
91
92 const running = next.filter(t => t.status === 'in_progress')
93 const started = running.filter(t => !wasRunning.has(t.id))
94 const was = await read($, currentTask)
95 const stillRunning = running.some(t => t.id === was)
96 const now_ = started.at(-1)?.id ?? (stillRunning ? was : (running.at(-1)?.id ?? ''))
97
98 if (now_ !== was) {
99 await update($, currentTask, () => now_)
100 }
101
102 if (now_ !== '' && now_ !== was) {
103 await update($, cards, list => list.filter(c => c.status === 'running' || c.taskKey === now_))
104 }
105
106 const wasFinished = before.length === 0 || before.every(t => t.status === 'completed')
107
108 if (wasFinished) {
109 await update($, isBandHidden, () => false)
110 }
111}
112
113export const register: Register = on => {
114 let createdTasks = 0
115
116 on('session.start', async ($, e, next) => {
117 await $.command.register({
118 name: 'helper-desk',
119 description: 'Open the subagent panel (live cards, click one to open its session)',
120 })
121 void $.ui.open({ id: PANE, title: 'Helper Desk' })
122
123 // Redraw twice a second while a task or an agent runs, so spinners and clocks move.
124 $.clock.every(TICK_MS, () => {
125 void (async () => {
126 const isTaskRunning = (await read($, todos)).some(t => t.status === 'in_progress')
127 const isAgentRunning = (await read($, cards)).some(c => c.status === 'running')
128
129 if (isTaskRunning || isAgentRunning) {
130 $.ui.invalidate('ui.render')
131 }
132 })()
133 })
134
135 return next(e)
136 })
137
138 on('command.run', { command: 'helper-desk' }, async $ => {
139 await $.ui.open({ id: PANE, title: 'Helper Desk', focus: true })
140
141 return { text: 'Helper Desk opened.' }
142 })
143
144 // A new prompt with no task running is a new job: finished agents of the last one go.
145 on('prompt.submit', async ($, e, next) => {
146 const isUsersOwn = e.origin.kind === 'composer' && !e.command && !e.isMeta
147
148 if (isUsersOwn && (await read($, currentTask)) === '') {
149 await update($, cards, list => list.filter(c => c.status === 'running'))
150 }
151
152 return next(e)
153 })
154
155 on('agent.spawn', async ($, e, next) => {
156 const started = await next(e)
157
158 if (started.deny === undefined && started.agentId !== undefined) {
159 const id = started.agentId
160 const now = await $.clock.now()
161 const taskKey = await read($, currentTask)
162 const card: Card = {
163 id,
164 name: shorten(e.description || e.subagentType, 28),
165 type: e.subagentType,
166 model: started.model,
167 status: 'running',
168 taskKey,
169 startedAt: now,
170 tokens: 0,
171 steps: 0,
172 note: 'starting',
173 }
174 await update($, cards, list => [...list, card].slice(-40))
175 }
176
177 return started
178 })
179
180 // Tokens count per step, so a running card is live; turn.complete settles the total.
181 on('turn.step', async function* ($, e, next) {
182 const response = yield* next(e)
183 const id = e.agentId
184
185 if (id !== undefined && response.usage) {
186 const u = response.usage
187 const spent = u.input_tokens + u.output_tokens + u.cache_creation_input_tokens
188 await update($, cards, list => list.map(c => (c.id === id ? { ...c, tokens: c.tokens + spent } : c)))
189 }
190
191 return response
192 })
193
194 on('tool.call', async ($, e, next) => {
195 const input = e as unknown as Record<string, unknown>
196
197 // The task list is applied before the call runs, so agents spawned in the same
198 // message already belong to the task that was just started.
199 if (e.agentId === undefined) {
200 if (e.tool.endsWith('work_state_write')) {
201 // claude-mem's work_state_write is this setup's to-do list: one entry per call.
202 const fields = input.fields as Record<string, unknown> | undefined
203 const task = fields?.task
204 const listName = typeof input.list === 'string' ? input.list : 'tasks'
205
206 if (typeof task === 'string') {
207 const id = `${listName}:${task}`
208 const status: Todo['status'] =
209 fields?.status === 'done' ? 'completed' : fields?.status === 'doing' ? 'in_progress' : 'pending'
210 const item: Todo = { id, content: task, active: task, status }
211 const list = await read($, todos)
212 const has = list.some(t => t.id === id)
213 const nextList =
214 fields?.status === 'dropped'
215 ? list.filter(t => t.id !== id)
216 : has
217 ? list.map(t => (t.id === id ? { ...t, status } : t))
218 : [...list, item]
219 await applyTodos($, nextList)
220 }
221 } else if (e.tool === 'TodoWrite') {
222 await applyTodos($, toTodos(input.todos))
223 } else if (e.tool === 'TaskCreate' && typeof input.subject === 'string') {
224 createdTasks += 1
225 const item: Todo = {
226 id: String(createdTasks),
227 content: input.subject,
228 active: typeof input.activeForm === 'string' ? input.activeForm : input.subject,
229 status: 'pending',
230 }
231 await applyTodos($, [...(await read($, todos)), item])
232 } else if (e.tool === 'TaskUpdate' && typeof input.taskId === 'string') {
233 const taskId = input.taskId
234 const status = input.status
235 const list = await read($, todos)
236 await applyTodos(
237 $,
238 status === 'deleted'
239 ? list.filter(t => t.id !== taskId)
240 : list.map(t =>
241 t.id === taskId
242 ? {
243 ...t,
244 content: typeof input.subject === 'string' ? input.subject : t.content,
245 active: typeof input.activeForm === 'string' ? input.activeForm : t.active,
246 status:
247 status === 'completed' || status === 'in_progress' || status === 'pending'
248 ? status
249 : t.status,
250 }
251 : t,
252 ),
253 )
254 }
255 }
256
257 const ran = await next(e)
258
259 if (e.agentId !== undefined && ran.deny === undefined) {
260 // The hand-back is the agent's last act, not a step worth showing.
261 if ((e.tool as string) === 'SubagentHandback') {
262 return ran
263 }
264
265 const id = e.agentId
266 const note = `${e.tool} ${aim(input)}`.trim()
267 await update($, cards, list =>
268 list.map(c => (c.id === id ? { ...c, steps: c.steps + 1, note } : c)),
269 )
270 }
271
272 return ran
273 })
274
275 on('turn.complete', async ($, e, next) => {
276 const id = e.agentId
277
278 if (id !== undefined) {
279 const now = await $.clock.now()
280 const spent = e.usage
281 ? e.usage.input_tokens + e.usage.output_tokens + e.usage.cache_creation_input_tokens
282 : 0
283 const status = e.reason === 'answer' ? 'done' : 'failed'
284 // turn.step already counted the steps; keep whichever total is larger.
285 await update($, cards, list =>
286 list.map(c =>
287 c.id === id ? { ...c, status, endedAt: now, tokens: Math.max(c.tokens, spent) } : c,
288 ),
289 )
290 }
291
292 return next(e)
293 })
294
295 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
296 const list = await read($, todos)
297 const agents = await read($, cards)
298 const hasTasks = list.length > 0 && !(await read($, isBandHidden))
299 const live = agents.filter(c => c.status === 'running')
300
301 if ((!hasTasks && live.length === 0) || e.props.hasSurvey) {
302 return next(e)
303 }
304
305 const { Box, Button, Text } = $.ui.resolve(e)
306 const now = await $.clock.now()
307 const finishedCount = agents.length - live.length
308 const spent = agents.reduce((sum, c) => sum + c.tokens, 0)
309 const since = live.reduce((min, c) => Math.min(min, c.startedAt), now)
310 // A one-line agent summary, so the subagents show even where the pane has no room.
311 const agentLine =
312 agents.length > 0 ? (
313 <Text color={ORANGE} wrap="truncate-end">
314 {`🦀 ${live.length} running · ${finishedCount} done · ${formatTokens(spent)} tokens${live.length > 0 ? ` · ${formatTime(now - since)}` : ''}`}
315 </Text>
316 ) : null
317
318 if (!hasTasks) {
319 return <Box paddingX={1}>{agentLine}</Box>
320 }
321
322 const done = list.filter(t => t.status === 'completed').length
323 const running = list.find(t => t.id === (list.find(x => x.status === 'in_progress')?.id))
324 const nextUp = list.find(t => t.status === 'pending')
325 const percent = Math.round((done / list.length) * 100)
326 const width = Math.max(10, Math.min(40, e.props.bodyColumns - 36))
327 const filled = Math.round((width * done) / list.length)
328
329 return (
330 <Box flexDirection="column" paddingX={1}>
331 <Box gap={1}>
332 <Text color={PURPLE}>{running ? spinnerAt(now) : '●'}</Text>
333 <Text weight="bold">Tasks</Text>
334 <Text backgroundColor={PURPLE} color="white" weight="bold">
335 {` ${done}/${list.length} `}
336 </Text>
337 <Text color={PURPLE}>{'█'.repeat(filled)}</Text>
338 <Text dimColor>{'░'.repeat(width - filled)}</Text>
339 <Text>{`${percent}%`}</Text>
340 <Button key="hide" label="×" onPress={() => update($, isBandHidden, () => true)} />
341 </Box>
342 <Text color={running ? PURPLE : undefined} dimColor={!running} wrap="truncate-end">
343 {running
344 ? `Running: ${running.active}${running.startedAt !== undefined ? ` · ${formatTime(now - running.startedAt)}` : ''}`
345 : nextUp
346 ? `Next: ${nextUp.active}`
347 : 'All tasks done'}
348 </Text>
349 {agentLine}
350 </Box>
351 )
352 })
353
354 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
355 const { Box, Button, Text } = $.ui.resolve(e)
356 const list = await read($, cards)
357 const tasks = await read($, todos)
358 const now = await $.clock.now()
359 const usage = await $.session.usage()
360 const running = list.filter(c => c.status === 'running')
361 const finished = list.filter(c => c.status !== 'running').reverse()
362 const room = Math.max(1, Math.floor(((e.viewport?.rows ?? 30) - 11) / CARD_ROWS))
363 const shownRunning = running.slice(0, room)
364 const shownFinished = finished.slice(0, Math.max(0, room - shownRunning.length))
365 const hidden = list.length - shownRunning.length - shownFinished.length
366 const totalTokens = list.reduce((sum, c) => sum + c.tokens, 0)
367 const first = list.reduce((min, c) => Math.min(min, c.startedAt), now)
368 const colorOf = (c: Card) => COLORS[list.indexOf(c) % COLORS.length]
369 const barWidth = Math.max(10, (e.viewport?.columns ?? 40) - 6)
370 const doneTasks = tasks.filter(t => t.status === 'completed').length
371 const activeTask = tasks.find(t => t.status === 'in_progress')
372
373 const renderCard = (c: Card) => {
374 const color = colorOf(c)
375 const end = c.endedAt ?? now
376 const isRunning = c.status === 'running'
377 const glyph = isRunning ? spinnerAt(now) : c.status === 'done' ? '✓' : '✗'
378
379 return (
380 <Box key={c.id} flexDirection="column" borderStyle="round" borderColor={isRunning ? color : undefined} borderDimColor={!isRunning} paddingX={1}>
381 <Box justifyContent="space-between">
382 <Button key={`open:${c.id}`} onPress={() => $.ui.view({ agentId: c.id })}>
383 <Text color={color} weight="bold">{`${glyph} 🦀 ${c.name}`}</Text>
384 </Button>
385 <Text dimColor>{`${formatTokens(c.tokens)} · ${formatTime(end - c.startedAt)}`}</Text>
386 </Box>
387 <Text dimColor wrap="truncate-end">{`${c.type} · ${shortModel(c.model)} · click to open`}</Text>
388 <Text color={isRunning ? undefined : c.status === 'done' ? GREEN : 'red'} wrap="truncate-end">
389 {isRunning
390 ? `Running · step ${c.steps} · ${c.note}`
391 : `${c.status === 'done' ? 'Done' : 'Stopped'} · ${c.steps} steps · ${c.note}`}
392 </Text>
393 <Text color={isRunning ? color : c.status === 'done' ? GREEN : 'red'}>{barOf(barWidth, now, isRunning)}</Text>
394 </Box>
395 )
396 }
397
398 return (
399 <Box flexDirection="column">
400 <Box justifyContent="space-between">
401 <Text color={ORANGE} weight="bold">{`${list.length} subagent${list.length === 1 ? '' : 's'} in this task`}</Text>
402 <Button key="main" label="Main chat" onPress={() => $.ui.view({})} />
403 </Box>
404 <Box gap={2}>
405 <Text color={running.length > 0 ? GREEN : undefined} dimColor={running.length === 0} weight="bold">{`● ${running.length} running`}</Text>
406 <Text color={GREEN} dimColor={finished.length === 0}>{`✓ ${finished.length} done`}</Text>
407 {tasks.length > 0 && (
408 <Text color={PURPLE} weight="bold">{`Tasks ${doneTasks}/${tasks.length}`}</Text>
409 )}
410 </Box>
411 {activeTask && (
412 <Text color={PURPLE} wrap="truncate-end">
413 {`${spinnerAt(now)} ${activeTask.active}${activeTask.startedAt !== undefined ? ` · ${formatTime(now - activeTask.startedAt)}` : ''}`}
414 </Text>
415 )}
416 <Box gap={2}>
417 <Text dimColor>{`Chat ${usage.cost ? `$${usage.cost.usd.toFixed(2)}` : 'n/a'}`}</Text>
418 <Text dimColor>{`${formatTokens(totalTokens)} tokens`}</Text>
419 <Text dimColor>{`Time ${formatTime(now - first)}`}</Text>
420 </Box>
421 {list.length === 0 && <Text dimColor>No subagents yet.</Text>}
422 {shownRunning.length > 0 && (
423 <Text backgroundColor={GREEN} color="white" weight="bold">{` Running · ${running.length} `}</Text>
424 )}
425 {shownRunning.map(renderCard)}
426 {shownFinished.length > 0 && (
427 <Text backgroundColor={AMBER} color="white" weight="bold">{` Finished · ${finished.length} `}</Text>
428 )}
429 {shownFinished.map(renderCard)}
430 {hidden > 0 && <Text dimColor>{`+${hidden} more`}</Text>}
431 </Box>
432 )
433 })
434}
435types/index.d.ts 36 lines1export type Todo = {
2 id: string
3 content: string
4 active: string
5 status: 'pending' | 'in_progress' | 'completed'
6 // When it last became in_progress, in $.clock.now() milliseconds.
7 startedAt?: number
8}
9
10export type Card = {
11 id: string
12 name: string
13 type: string
14 model: string
15 status: 'running' | 'done' | 'failed'
16 // The task in progress when the agent was spawned; '' when none was.
17 taskKey: string
18 startedAt: number
19 endedAt?: number
20 tokens: number
21 steps: number
22 note: string
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 'helper-desk': {
28 todos: Shaped<Todo[]>
29 cards: Shaped<Card[]>
30 // The id of the task most recently moved to in_progress; '' when none runs.
31 currentTask: string
32 isBandHidden: boolean
33 }
34 }
35}
36