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).

A Claude Code mod that shows how far Claude is with its to-do list and what each subagent does.
3/5, and the item in progress. When all items are done, the bar goes away at the end of the turn./helpers shows or hides it. After you close it, it stays closed until you run /helpers./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.
In a Claude Code terminal session:
/plugin install helper-desk --marketplace whykothari/whystack
To run a local copy instead, see the mods README.
Inspired by a mod idea from Ben AI on YouTube. The code is new and written for whystack.
hooks/register.tsx 230 lines1import { 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}
230hooks/codex.ts 75 lines1import 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}
75types/index.d.ts 44 lines1export 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