SLOPSHOPPER

helper-desk

A task bar above the prompt that fills as Claude's to-do list is done, and a Helpers pane with one live card per subagent or Codex run (/helpers).

newpanebandguardcommandtimer
v0.2.0MITupdated 2026-10-23whykothari/whystack/mods/helper-desk
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · helper-desk
│ ┃ Helpers ✕ › fix the failing auth test and add an audit log call │ ┃ No helpers yet. Subagents and Codex runs │ ┃ show here while they work. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ 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 │ │ › /helpers │ ⎿ helper-desk: Helpers pane open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Helpers
No helpers yet. Subagents and Codex runs show here while they work.
README

helper-desk

A Claude Code mod that shows how far Claude is with its to-do list and what each subagent does.

  • Task bar: above the prompt, a bar fills as Claude marks items done on its to-do list. It shows the count, such as 3/5, and the item in progress. When all items are done, the bar goes away at the end of the turn.
  • Helpers pane: one card per subagent, with its task, type, model, time, tool calls and its last tool. When the subagent keeps its own to-do list, the card shows a progress bar for it. The pane opens when the first subagent starts. /helpers shows or hides it. After you close it, it stays closed until you run /helpers.
  • Codex cards: one card per Codex run that this session starts, with Codex's phase, time and, at the end, its summary. It covers the Codex plugin jobs (/codex:rescue, /codex:review, background jobs) and each codex exec call in the foreground. The codex-rescue subagent and the job it starts share one card.

The Helpers button on the task bar opens the pane while subagents run.

Notes

  • The band above the prompt holds one mod at a time. crabwatch, cache-tax and replay-theater also draw there, so only one of them shows.
  • crabwatch has a fuller agents panel, with context, cost and savvy-flow progress. helper-desk is the light version for any to-do list.

Install

In a Claude Code terminal session:

/plugin install helper-desk --marketplace whykothari/whystack

To run a local copy instead, see the mods README.

Credits

Inspired by a mod idea from Ben AI on YouTube. The code is new and written for whystack.

Source 3 files
hooks/register.tsx 230 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Helper, Task } from '../types'
5import { readCodexJobs, runsCodexExec, runsCodexPlugin, withCodexJobs } from './codex'
6
7// The band above the prompt follows the main to-do list. The pane has one
8// card per subagent or Codex run ("helper"), live while it runs.
9
10const PANE = 'helper-desk'
11const COMMAND = 'helpers'
12const BAR = 20
13const CARDS_MAX = 8
14
15const tasks = atom({ plugin: 'helper-desk', key: 'tasks' } as const, [] as Task[])
16const helpers = atom({ plugin: 'helper-desk', key: 'helpers' } as const, [] as Helper[])
17const now = atom({ plugin: 'helper-desk', key: 'now' } as const, 0)
18
19let isPaneOpen = false
20// After the person closes the pane, a new helper doesn't open it again.
21let isPaneDismissed = false
22let sessionId = ''
23// Codex jobs run on after the Bash call that starts them, so the clock reads
24// the job list while one runs.
25let isWatchingCodex = false
26
27function bar(done: number, total: number, width = BAR): string {
28  const full = total === 0 ? 0 : Math.round((done / total) * width)
29  return '█'.repeat(full) + '░'.repeat(width - full)
30}
31
32function clock(ms: number): string {
33  const s = Math.max(0, Math.floor(ms / 1000))
34  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
35}
36
37async function openPane($: EngineInterface) {
38  const opened = await $.ui.open({ id: PANE, title: 'Helpers' })
39  isPaneOpen = opened.isPlaced
40}
41
42async function syncCodex($: EngineInterface) {
43  const jobs = await readCodexJobs($, sessionId)
44  isWatchingCodex = jobs.some(j => j.status === 'queued' || j.status === 'running')
45  const before = (await read($, helpers)).length
46  await update($, helpers, all => withCodexJobs(all, jobs))
47  if ((await read($, helpers)).length > before && !isPaneOpen && !isPaneDismissed) await openPane($)
48}
49
50export const register: Register = on => {
51  on('session.start', async ($, e, next) => {
52    const started = await next(e)
53    await $.command.register({ name: COMMAND, description: 'Show or hide the Helpers pane: one card per subagent or Codex run' })
54    sessionId = await $.session.id()
55    // A reload loses the module's variables, and a Codex job may still run.
56    await syncCodex($)
57    // Moves the running helpers' clocks; quiet when none runs.
58    $.clock.every(1000, () => {
59      void (async () => {
60        if (isWatchingCodex) await syncCodex($)
61        if (!(await read($, helpers)).some(h => h.status === 'running')) return
62        const at = await $.clock.now()
63        await update($, now, () => at)
64      })()
65    })
66    return started
67  })
68
69  on('command.run', { command: COMMAND }, async $ => {
70    if (isPaneOpen) {
71      await $.ui.close({ id: PANE })
72      isPaneOpen = false
73      return { text: 'Helpers pane closed.' }
74    }
75    isPaneDismissed = false
76    await openPane($)
77    return { text: isPaneOpen ? 'Helpers pane open.' : 'No room for the pane here. Make the terminal wider.' }
78  })
79
80  on('ui.close', ($, e, next) => {
81    if (e.id === PANE) {
82      isPaneOpen = false
83      if (e.origin.kind === 'person') isPaneDismissed = true
84    }
85    return next(e)
86  })
87
88  on('tool.call', async ($, e, next) => {
89    if (e.tool === 'TodoWrite') {
90      const list = e.todos.map(t => ({ content: t.content, status: t.status, activeForm: t.activeForm }))
91      if (e.agentId === undefined) {
92        await update($, tasks, () => list)
93      } else {
94        const done = list.filter(t => t.status === 'completed').length
95        await update($, helpers, all =>
96          all.map(h => (h.agentId === e.agentId ? { ...h, todoDone: done, todoTotal: list.length } : h)),
97        )
98      }
99    }
100    if (e.agentId !== undefined) {
101      const tool = e.tool
102      await update($, helpers, all =>
103        all.map(h => (h.agentId === e.agentId ? { ...h, tools: h.tools + 1, lastTool: tool } : h)),
104      )
105    }
106    if (e.tool !== 'Bash') return next(e)
107    if (runsCodexPlugin(e.command)) {
108      isWatchingCodex = true
109      const result = await next(e)
110      await syncCodex($)
111      return result
112    }
113    // A background run returns at once and leaves no job to follow.
114    if (!runsCodexExec(e.command) || e.run_in_background) return next(e)
115    const id = `exec:${e.tool_use_id}`
116    const startedAt = await $.clock.now()
117    const card: Helper = { id, description: 'codex exec', type: 'Codex · exec', model: '', status: 'running', startedAt, tools: 0 }
118    await update($, helpers, all => [...all, card].slice(-50))
119    await update($, now, () => startedAt)
120    if (!isPaneOpen && !isPaneDismissed) await openPane($)
121    const result = await next(e)
122    const endedAt = await $.clock.now()
123    const status = result.deny === undefined && !result.isError ? 'done' : 'failed'
124    await update($, helpers, all => all.map(h => (h.id === id ? { ...h, status, endedAt } : h)))
125    await update($, now, () => endedAt)
126    return result
127  })
128
129  on('agent.spawn', async ($, e, next) => {
130    const started = await next(e)
131    if (started.deny !== undefined) return started
132    const at = await $.clock.now()
133    const helper: Helper = {
134      id: started.agentId ?? e.tool_use_id,
135      agentId: started.agentId,
136      description: e.description,
137      type: e.subagentType,
138      model: started.model,
139      status: 'running',
140      startedAt: at,
141      tools: 0,
142    }
143    await update($, helpers, all => [...all.filter(h => h.id !== helper.id), helper].slice(-50))
144    await update($, now, () => at)
145    if (!isPaneOpen && !isPaneDismissed) await openPane($)
146    return started
147  })
148
149  on('turn.complete', async ($, e, next) => {
150    const result = await next(e)
151    const at = await $.clock.now()
152    if (e.agentId !== undefined) {
153      const status = e.reason === 'answer' && !e.isAborted ? 'done' : 'failed'
154      await update($, helpers, all =>
155        all.map(h => (h.agentId === e.agentId ? { ...h, status, endedAt: at } : h)),
156      )
157      await update($, now, () => at)
158    } else {
159      // A finished list leaves the band once its turn ends.
160      await update($, tasks, list => (list.every(t => t.status === 'completed') ? [] : list))
161    }
162    return result
163  })
164
165  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
166    const list = await read($, tasks)
167    if (e.props.hasSurvey || list.length === 0) return next(e)
168
169    const { Box, Text, Button } = $.ui.resolve(e)
170    const done = list.filter(t => t.status === 'completed').length
171    const current = list.find(t => t.status === 'in_progress')
172    const running = (await read($, helpers)).filter(h => h.status === 'running').length
173    const width = Math.max(8, Math.min(BAR, e.props.bodyColumns - 40))
174
175    return (
176      <Box flexDirection="row" gap={1}>
177        <Text color="magenta" bold>Tasks</Text>
178        <Text color="magenta">{bar(done, list.length, width)}</Text>
179        <Text bold>{done}/{list.length}</Text>
180        {current ? <Text dimColor wrap="truncate-end">{current.activeForm}</Text> : null}
181        {running > 0 ? (
182          <Button key="helpers" label={`Helpers ${running}`} onPress={() => openPane($)} />
183        ) : null}
184      </Box>
185    )
186  })
187
188  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
189    const { Box, Text } = $.ui.resolve(e)
190    const at = await read($, now)
191    const all = await read($, helpers)
192    const shown = [...all.filter(h => h.status === 'running'), ...all.filter(h => h.status !== 'running').reverse()].slice(0, CARDS_MAX)
193    const width = Math.max(8, Math.min(BAR, e.props.bodyColumns - 20))
194
195    if (shown.length === 0) {
196      return <Text dimColor>No helpers yet. Subagents and Codex runs show here while they work.</Text>
197    }
198
199    return (
200      <Box flexDirection="column" gap={1}>
201        {shown.map(helper => {
202          const color = helper.status === 'running' ? 'magenta' : helper.status === 'done' ? 'green' : 'red'
203          const mark = helper.status === 'running' ? '●' : helper.status === 'done' ? '✓' : '✗'
204          const elapsed = clock((helper.endedAt ?? Math.max(at, helper.startedAt)) - helper.startedAt)
205          const progress =
206            helper.todoTotal !== undefined
207              ? `${bar(helper.todoDone ?? 0, helper.todoTotal, width)} ${helper.todoDone ?? 0}/${helper.todoTotal}`
208              : helper.status === 'running'
209                ? helper.phase ?? 'working'
210                : (helper.summary ?? bar(1, 1, width))
211          const activity = helper.codexJobId
212            ? `Codex ${helper.phase ?? helper.status} · ${elapsed}`
213            : `${helper.tools} tool calls · ${elapsed}${helper.lastTool && helper.status === 'running' ? ` · ${helper.lastTool}` : ''}`
214          return (
215            <Box key={helper.id} flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
216              <Text bold wrap="truncate-end">
217                <Text color={color}>{mark} </Text>
218                {helper.description || helper.type}
219              </Text>
220              <Text dimColor wrap="truncate-end">{[helper.type, helper.model].filter(Boolean).join(' · ')}</Text>
221              <Text color={color} wrap="truncate-end">{progress}</Text>
222              <Text dimColor wrap="truncate-end">{activity}</Text>
223            </Box>
224          )
225        })}
226      </Box>
227    )
228  })
229}
230
hooks/codex.ts 75 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { CodexJob, Helper, HelperStatus } from '../types'
4
5// The Codex plugin (openai/codex-plugin-cc) keeps one state.json per workspace
6// in its plugin data folder. Each job names the Claude session that started it.
7export async function readCodexJobs($: EngineInterface, sessionId: string): Promise<CodexJob[]> {
8  const config = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${await $.env.get('HOME')}/.claude`
9  const root = `${config}/plugins/data/codex-openai-codex/state`
10  if (!(await $.fs.exists(root))) return []
11  const jobs: CodexJob[] = []
12  for (const entry of await $.fs.list(root)) {
13    if (entry.kind !== 'dir') continue
14    try {
15      const state = JSON.parse(await $.fs.read(`${root}/${entry.name}/state.json`))
16      for (const job of state.jobs ?? []) if (job.sessionId === sessionId) jobs.push(job)
17    } catch {
18      // A workspace with no state yet, or a file the plugin is writing now.
19    }
20  }
21  return jobs
22}
23
24export const runsCodexPlugin = (command: string) => command.includes('codex-companion.mjs')
25
26export const runsCodexExec = (command: string) => /(^|[\s;&|(])codex\s+(exec|e)\s/.test(command)
27
28function statusOf(job: CodexJob): HelperStatus {
29  if (job.status === 'completed') return 'done'
30  if (job.status === 'failed' || job.status === 'cancelled') return 'failed'
31  return 'running'
32}
33
34const timeOf = (iso: string | undefined) => (iso ? Date.parse(iso) : undefined)
35
36// One card per job. A codex-rescue subagent starts its job through Bash, so a
37// new job goes on that subagent's card when one is running without a job.
38export function withCodexJobs(all: Helper[], jobs: CodexJob[]): Helper[] {
39  let next = all
40  for (const job of jobs) {
41    const live = { phase: job.phase ?? undefined, summary: job.summary ?? undefined }
42    const card = next.find(h => h.codexJobId === job.id)
43    if (card) {
44      next = next.map(h =>
45        h !== card ? h
46        : h.agentId !== undefined ? { ...h, ...live }
47        : { ...h, ...live, status: statusOf(job), endedAt: timeOf(job.completedAt) },
48      )
49      continue
50    }
51    const rescue = next.find(h => h.status === 'running' && h.type.endsWith('codex-rescue') && !h.codexJobId)
52    if (rescue) {
53      next = next.map(h => (h === rescue ? { ...h, ...live, codexJobId: job.id } : h))
54      continue
55    }
56    const startedAt = timeOf(job.startedAt ?? job.createdAt) ?? 0
57    next = [
58      ...next,
59      {
60        id: `codex:${job.id}`,
61        codexJobId: job.id,
62        description: job.title || `Codex ${job.kind}`,
63        type: `Codex · ${job.kind}`,
64        model: '',
65        status: statusOf(job),
66        startedAt,
67        endedAt: timeOf(job.completedAt),
68        tools: 0,
69        ...live,
70      },
71    ].slice(-50)
72  }
73  return next
74}
75
types/index.d.ts 44 lines
1export type TaskStatus = 'pending' | 'in_progress' | 'completed'
2
3export type Task = { content: string; status: TaskStatus; activeForm: string }
4
5export type HelperStatus = 'running' | 'done' | 'failed'
6
7export type Helper = {
8  id: string
9  agentId?: string
10  description: string
11  type: string
12  model: string
13  status: HelperStatus
14  startedAt: number
15  endedAt?: number
16  tools: number
17  lastTool?: string
18  todoDone?: number
19  todoTotal?: number
20  codexJobId?: string
21  phase?: string
22  summary?: string
23}
24
25// A job as the Codex plugin writes it in its state.json.
26export type CodexJob = {
27  id: string
28  kind: string
29  title?: string
30  status: string
31  phase?: string | null
32  summary?: string | null
33  sessionId?: string
34  createdAt?: string
35  startedAt?: string
36  completedAt?: string
37}
38
39declare module 'claude-code' {
40  interface PluginState {
41    'helper-desk': { tasks: Task[]; helpers: Helper[]; now: number }
42  }
43}
44