SLOPSHOPPER

helper-desk

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…

newpanebandguardcommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · helper-desk
│ ┃ Helper Desk ✕ › fix the failing auth test and add an audit log call │ ┃ 0 subagents in this task [ Main chat ] │ ┃ ● 0 running ✓ 0 done ⏺ Read(src/auth.ts) │ ┃ Chat $0.42 0 tokens Time 0:00 ⎿ Read 6 lines │ ┃ No subagents yet. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /helper-desk │ ⎿ helper-desk: Helper Desk opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Helper Desk
0 subagents in this task [ Main chat ] ● 0 running ✓ 0 done Chat $0.42 0 tokens Time 0:00 No subagents yet.
README

Helper Desk

A Claude Code mod that shows what Claude and its subagents are doing, without a single model call.

  • Task bar. A purple bar above the prompt fills as Claude ticks off its to-do list.
  • Subagent panel. /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.

Notes

  • Claude Code only.

Install

/plugin install helper-desk --marketplace FCisco95/claude-code-mods

Or copy plugins/helper-desk to ~/.claude/skills/helper-desk (see the repo README).

Source 2 files
hooks/register.tsx 435 lines
1import { 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}
435
types/index.d.ts 36 lines
1export 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