One pane for the session's subagents and Claude's own task list

One pane for the work in your session: every subagent (running and finished, nested under whoever spawned it, with what a running one is doing now) and Claude's own task list.
┌ Agents & tasks ───────────────┐
│ Agents 1 running · 1 done │
│ ● Build commit-guard 4m 12s │
│ └ Bash: npm test │
│ ✓ Write READMEs 3m 44s │
│ │
│ Tasks 3/7 │
│ ▸ Write the tests │
│ ○ Run validate │
└───────────────────────────────┘
agent-tasks is a Claude Code mod (a plugin of function hooks). It keeps nothing between sessions.
/plugin marketplace add zulfikar-ditya/claude-mods-agent-tasks
/plugin install agent-tasks@agent-tasks
Or load it from a local folder, for one session with claude --plugin-dir <path to agent-tasks>, or for every session by adding the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json (several folders are separated by :). Needs Claude Code 2.1.287 or later.
agents 2 running · tasks 3/7, gone when no subagent runs and every task is done./agent-tasks opens it at any width.It does not read todo.md; mission-control does that. Loading both shows two status line entries.
/agent-tasks: open the pane.In /config under agent-tasks, or in ~/.claude/settings.json under pluginConfigs.agent-tasks.options:
| Setting | Default | |
|---|---|---|
autoOpen | true | Open the pane when a subagent starts or the task list appears |
status | true | Show the status line entry |
autoOpen to false.status to false.--plugin-dir / CLAUDE_CODE_PLUGIN_DIRS and run /reload-plugins.claude --plugin-dir . # run it from this folder
claude plugin validate .
claude plugin test .
hooks/tracker.ts is kept byte-identical to the copies in mission-control and audit-flow.
MIT, see LICENSE.
hooks/register.tsx 318 lines1/**
2 * agent-tasks: one pane with the session's subagents and Claude's own task list.
3 *
4 * `/agent-tasks` opens the pane; it opens by itself (wide terminals only) when a
5 * subagent starts or the task list appears. The status line reads
6 * `agents 2 running · tasks 3/7` while there is work under way.
7 *
8 * Turning it off:
9 * - The pane opening by itself: `/config` → agent-tasks → "Open the pane by itself" off.
10 * - The status line entry: `/config` → agent-tasks → "Status line entry" off.
11 * - Everywhere: drop the folder from `--plugin-dir` / `CLAUDE_CODE_PLUGIN_DIRS`
12 * and `/reload-plugins`. It keeps nothing between sessions.
13 */
14import { atom, read, update } from 'claude-code'
15import type { EngineInterface, Register } from 'claude-code'
16
17import type { TaskItem } from '../types'
18import { actionOf, called, endOf, ended, merged, spawned, treeOf } from './tracker'
19import {
20 actionLine,
21 agentLine,
22 agentsHeader,
23 fromTodos,
24 isActive,
25 statusText,
26 taskLine,
27 tasksHeader,
28 updatedTasks,
29} from './view'
30
31const agents = atom({ plugin: 'agent-tasks', key: 'agents' } as const, [])
32const now = atom({ plugin: 'agent-tasks', key: 'now' } as const, 0)
33const tasks = atom({ plugin: 'agent-tasks', key: 'tasks' } as const, [])
34
35const PANE = 'agent-tasks'
36const TITLE = 'Agents & tasks'
37const TICK_MS = 1_000
38
39type Config = { isAutoOpen: boolean; isStatus: boolean }
40
41/** Whether the pane opened by itself during this stretch of work (reset once nothing is under way). */
42let hasAutoOpened = false
43
44/** Redraws the status line from what is held now. */
45async function showStatus($: EngineInterface, config: Config) {
46 if (!config.isStatus) {
47 return
48 }
49
50 const text = statusText(await read($, agents), await read($, tasks))
51
52 $.ui.status(text === '' ? undefined : text)
53}
54
55/** Opens the pane unasked when work has just started; re-arms once nothing is under way. */
56async function autoOpen($: EngineInterface, config: Config) {
57 const isBusy = isActive(await read($, agents), await read($, tasks))
58
59 if (!isBusy) {
60 hasAutoOpened = false
61 return
62 }
63
64 if (config.isAutoOpen && !hasAutoOpened) {
65 hasAutoOpened = true
66 await $.ui.open({ id: PANE, title: TITLE })
67 }
68}
69
70async function tick($: EngineInterface) {
71 const rows = await read($, agents)
72
73 if (rows.some(row => row.status === 'running')) {
74 const at = await $.clock.now()
75 await update($, now, () => at)
76 }
77}
78
79/** Fills in subagents started before this module loaded (a reload), and closes finished ones. */
80async function syncList($: EngineInterface, config: Config) {
81 const listed = await $.agent.list().catch(() => [])
82 const at = await $.clock.now()
83 const rows = merged(await read($, agents), listed, at)
84
85 await update($, agents, () => rows)
86 await update($, now, () => at)
87 await showStatus($, config)
88}
89
90async function noteSpawn(
91 $: EngineInterface,
92 config: Config,
93 e: { description: string; subagentType: string; parentAgentId?: string; background: boolean },
94 agentId: string,
95) {
96 const at = await $.clock.now()
97 const rows = spawned(await read($, agents), {
98 id: agentId,
99 description: e.description,
100 type: e.subagentType,
101 isBackground: e.background,
102 at,
103 ...(e.parentAgentId === undefined ? {} : { parentId: e.parentAgentId }),
104 })
105
106 await update($, agents, () => rows)
107 await update($, now, () => at)
108 await showStatus($, config)
109 await autoOpen($, config)
110}
111
112async function noteCall($: EngineInterface, config: Config, agentId: string, action: string) {
113 const at = await $.clock.now()
114 const rows = called(await read($, agents), agentId, action, at)
115
116 await update($, agents, () => rows)
117 await showStatus($, config)
118}
119
120async function noteEnd($: EngineInterface, config: Config, agentId: string, reason: string) {
121 const before = await read($, agents)
122
123 if (!before.some(row => row.id === agentId)) {
124 return
125 }
126
127 const at = await $.clock.now()
128
129 await update($, agents, () => ended(before, agentId, endOf(reason), at))
130 await update($, now, () => at)
131 await showStatus($, config)
132 await autoOpen($, config)
133}
134
135/** Replaces the task list and follows up with the status line and the auto-open. */
136async function setTasks($: EngineInterface, config: Config, next: (held: TaskItem[]) => TaskItem[]) {
137 await update($, tasks, held => next(held ?? []))
138 await showStatus($, config)
139 await autoOpen($, config)
140}
141
142export const register: Register = (on, options) => {
143 const config: Config = {
144 isAutoOpen: options.autoOpen !== false,
145 isStatus: options.status !== false,
146 }
147
148 on('session.start', async ($, e, next) => {
149 await $.command.register({
150 name: 'agent-tasks',
151 description: "Open the pane: this session's subagents and Claude's task list",
152 })
153 await syncList($, config)
154 $.clock.every(TICK_MS, () => void tick($))
155
156 return next(e)
157 })
158
159 on('command.run', { command: 'agent-tasks' }, async $ => {
160 await syncList($, config)
161 await $.ui.open({ id: PANE, title: TITLE })
162
163 return {}
164 })
165
166 on('agent.spawn', async ($, e, next) => {
167 const started = await next(e)
168
169 if (started.agentId !== undefined) {
170 await noteSpawn($, config, e, started.agentId)
171 }
172
173 return started
174 })
175
176 on('tool.call', async ($, e, next) => {
177 if (e.agentId !== undefined) {
178 await noteCall($, config, e.agentId, actionOf(String(e.tool), e as unknown as Record<string, unknown>))
179 }
180
181 return next(e)
182 })
183
184 on('turn.complete', async ($, e, next) => {
185 const done = await next(e)
186
187 if (e.agentId !== undefined) {
188 await noteEnd($, config, e.agentId, e.reason)
189 }
190
191 return done
192 })
193
194 // Claude's own task list: only the main loop's (a subagent's list is its own).
195 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
196 const ran = await next(e)
197
198 if (e.agentId === undefined && ran.deny === undefined) {
199 await setTasks($, config, () => fromTodos(e.todos))
200 }
201
202 return ran
203 })
204
205 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
206 const ran = await next(e)
207 const id = (ran.result as { task?: { id?: unknown } } | undefined)?.task?.id
208
209 if (e.agentId === undefined && typeof id === 'string') {
210 await setTasks($, config, held => [...held.filter(task => task.id !== id), { id, subject: e.subject, status: 'pending' }])
211 }
212
213 return ran
214 })
215
216 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
217 const ran = await next(e)
218
219 if (e.agentId === undefined && ran.deny === undefined) {
220 await setTasks($, config, held =>
221 updatedTasks(held, {
222 taskId: e.taskId,
223 ...(e.subject === undefined ? {} : { subject: e.subject }),
224 ...(e.status === undefined ? {} : { status: e.status }),
225 }),
226 )
227 }
228
229 return ran
230 })
231
232 // TaskList answers with the whole list: take it as the truth (it catches up after a reload).
233 on('tool.call', { tool: 'TaskList' }, async ($, e, next) => {
234 const ran = await next(e)
235 const listed = (ran.result as { tasks?: unknown } | undefined)?.tasks
236
237 if (e.agentId === undefined && Array.isArray(listed)) {
238 await setTasks($, config, () =>
239 (listed as Array<{ id: string; subject: string; status: TaskItem['status'] }>).map(task => ({
240 id: task.id,
241 subject: task.subject,
242 status: task.status,
243 })),
244 )
245 }
246
247 return ran
248 })
249
250 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
251 const { Box, Text } = $.ui.resolve(e)
252 const rows = await read($, agents)
253 const list = await read($, tasks)
254 const at = await read($, now)
255 const width = e.props.bodyColumns
256
257 if (rows.length === 0 && list.length === 0) {
258 return (
259 <Box flexDirection="column">
260 <Box key="empty">
261 <Text dimColor>No agents or tasks yet.</Text>
262 </Box>
263 </Box>
264 )
265 }
266
267 return (
268 <Box flexDirection="column">
269 {rows.length === 0 ? null : (
270 <Box flexDirection="column">
271 <Box key="agents">
272 <Text bold>{agentsHeader(rows, width)}</Text>
273 </Box>
274 {treeOf(rows).map(({ row, depth }) => {
275 const action = row.status === 'running' ? actionLine(row, depth, width) : ''
276
277 return (
278 <Box key={`row:${row.id}`} flexDirection="column">
279 <Box key={`agent:${row.id}`}>
280 <Text
281 wrap="truncate-end"
282 dimColor={row.status === 'done'}
283 color={row.status === 'failed' ? 'red' : undefined}
284 >
285 {agentLine(row, depth, at, width)}
286 </Text>
287 </Box>
288 {action === '' ? null : (
289 <Box key={`action:${row.id}`}>
290 <Text dimColor wrap="truncate-end">
291 {action}
292 </Text>
293 </Box>
294 )}
295 </Box>
296 )
297 })}
298 </Box>
299 )}
300 {list.length === 0 ? null : (
301 <Box flexDirection="column" marginTop={rows.length === 0 ? 0 : 1}>
302 <Box key="tasks">
303 <Text bold>{tasksHeader(list, width)}</Text>
304 </Box>
305 {list.map(task => (
306 <Box key={`task:${task.id}`}>
307 <Text wrap="truncate-end" dimColor={task.status === 'completed'} bold={task.status === 'in_progress'}>
308 {taskLine(task, width)}
309 </Text>
310 </Box>
311 ))}
312 </Box>
313 )}
314 </Box>
315 )
316 })
317}
318hooks/tracker.ts 275 lines1/**
2 * Subagent tracker: which subagents the session has, their status, tool calls
3 * and last action. Pure logic: nothing here touches `$`.
4 *
5 * KEPT IDENTICAL in two mods: `audit-flow/hooks/tracker.ts` and
6 * `mission-control/hooks/tracker.ts` (each mod is its own repo, so the module
7 * is copied, not shared). Change one, copy it over the other; the `AgentRow`
8 * and `AgentStatus` types it uses are in each mod's `types/index.d.ts`.
9 */
10import type { AgentRow, AgentStatus } from '../types'
11
12/** A subagent that just started, as `agent.spawn` tells it. */
13export type Spawn = {
14 id: string
15 description: string
16 type: string
17 parentId?: string
18 isBackground: boolean
19 at: number
20}
21
22/** A subagent as `$.agent.list()` names it. */
23export type Listed = {
24 id: string
25 description: string
26 type: string
27 status: string
28 parentId?: string
29}
30
31export type Tally = { running: number; done: number; failed: number; killed: number }
32
33const LINE = 80
34
35/** Text squeezed onto one line, cut to `max` characters. */
36export function oneLine(text: string, max = LINE): string {
37 const flat = text.replace(/\s+/g, ' ').trim()
38
39 return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
40}
41
42/** A tool call in a few words: `Read src/app.ts`, `Bash: npm test`. */
43export function actionOf(tool: string, input: Record<string, unknown>): string {
44 const text = (key: string) => (typeof input[key] === 'string' ? (input[key] as string) : '')
45
46 switch (tool) {
47 case 'Bash':
48 return oneLine(`Bash: ${text('command')}`)
49 case 'Read':
50 case 'Edit':
51 case 'Write':
52 return oneLine(`${tool} ${text('file_path')}`)
53 case 'NotebookEdit':
54 return oneLine(`NotebookEdit ${text('notebook_path')}`)
55 case 'Grep':
56 case 'Glob':
57 return oneLine(`${tool} ${text('pattern')}`)
58 case 'WebFetch':
59 return oneLine(`WebFetch ${text('url')}`)
60 case 'WebSearch':
61 return oneLine(`WebSearch ${text('query')}`)
62 case 'Agent':
63 return oneLine(`Agent: ${text('description')}`)
64 default:
65 return oneLine(tool)
66 }
67}
68
69/** The engine's task status in the tracker's words. */
70export function statusOf(status: string): AgentStatus {
71 if (status === 'completed' || status === 'done') {
72 return 'done'
73 }
74
75 if (status === 'failed' || status === 'error') {
76 return 'failed'
77 }
78
79 if (status === 'killed' || status === 'stopped' || status === 'aborted') {
80 return 'killed'
81 }
82
83 return 'running'
84}
85
86/** How a subagent's turn ended (`turn.complete`'s `reason`) as its status. */
87export function endOf(reason: string): AgentStatus {
88 if (reason === 'answer') {
89 return 'done'
90 }
91
92 return reason === 'aborted' ? 'killed' : 'failed'
93}
94
95/** The rows with a subagent that just started. */
96export function spawned(rows: readonly AgentRow[], spawn: Spawn): AgentRow[] {
97 const known = rows.find(row => row.id === spawn.id)
98 const fresh: AgentRow = {
99 id: spawn.id,
100 description: spawn.description,
101 type: spawn.type,
102 status: 'running',
103 isBackground: spawn.isBackground,
104 startedAt: known?.startedAt ?? spawn.at,
105 toolCalls: known?.toolCalls ?? 0,
106 lastAction: known?.lastAction ?? '',
107 ...(spawn.parentId === undefined ? {} : { parentId: spawn.parentId }),
108 }
109
110 return known === undefined ? [...rows, fresh] : rows.map(row => (row.id === spawn.id ? fresh : row))
111}
112
113/** The rows after one of the subagents made a tool call; an unknown one is added. */
114export function called(rows: readonly AgentRow[], agentId: string, action: string, at: number): AgentRow[] {
115 const known = rows.find(row => row.id === agentId)
116
117 if (known === undefined) {
118 return [
119 ...rows,
120 {
121 id: agentId,
122 description: 'subagent',
123 type: 'unknown',
124 status: 'running',
125 isBackground: false,
126 startedAt: at,
127 toolCalls: 1,
128 lastAction: action,
129 },
130 ]
131 }
132
133 return rows.map(row => {
134 if (row.id !== agentId) {
135 return row
136 }
137
138 const { endedAt: _ended, ...rest } = row
139
140 return { ...rest, status: 'running', toolCalls: row.toolCalls + 1, lastAction: action }
141 })
142}
143
144/** The rows after a subagent's turn ended; an unknown id changes nothing. */
145export function ended(rows: readonly AgentRow[], agentId: string, status: AgentStatus, at: number): AgentRow[] {
146 return rows.map(row => (row.id === agentId ? { ...row, status, endedAt: at } : row))
147}
148
149/** The rows merged with what `$.agent.list()` says: missing ones added, finished ones closed. */
150export function merged(rows: readonly AgentRow[], listed: readonly Listed[], at: number): AgentRow[] {
151 let next = [...rows]
152
153 for (const agent of listed) {
154 const status = statusOf(agent.status)
155 const known = next.find(row => row.id === agent.id)
156
157 if (known === undefined) {
158 next = [
159 ...next,
160 {
161 id: agent.id,
162 description: agent.description,
163 type: agent.type,
164 status,
165 isBackground: false,
166 startedAt: at,
167 toolCalls: 0,
168 lastAction: '',
169 ...(agent.parentId === undefined ? {} : { parentId: agent.parentId }),
170 ...(status === 'running' ? {} : { endedAt: at }),
171 },
172 ]
173 } else if (known.status === 'running' && status !== 'running') {
174 next = ended(next, agent.id, status, at)
175 }
176 }
177
178 return next
179}
180
181/** The rows in tree order (each parent followed by its children), with their depth. */
182export function treeOf(rows: readonly AgentRow[]): Array<{ row: AgentRow; depth: number }> {
183 const ids = new Set(rows.map(row => row.id))
184 const isRoot = (row: AgentRow) => row.parentId === undefined || !ids.has(row.parentId)
185 const out: Array<{ row: AgentRow; depth: number }> = []
186 const seen = new Set<string>()
187
188 const walk = (row: AgentRow, depth: number) => {
189 if (seen.has(row.id)) {
190 return
191 }
192
193 seen.add(row.id)
194 out.push({ row, depth })
195
196 for (const child of rows.filter(each => each.parentId === row.id)) {
197 walk(child, depth + 1)
198 }
199 }
200
201 for (const row of rows.filter(isRoot)) {
202 walk(row, 0)
203 }
204
205 return out
206}
207
208/** How many subagents stand at each status. */
209export function tallyOf(rows: readonly AgentRow[]): Tally {
210 const tally: Tally = { running: 0, done: 0, failed: 0, killed: 0 }
211
212 for (const row of rows) {
213 tally[row.status] += 1
214 }
215
216 return tally
217}
218
219/** `agents 3 running · 2 done · 1 failed`; empty when there are none. */
220export function tallyLine(tally: Tally): string {
221 const parts = [
222 tally.running > 0 ? `${tally.running} running` : '',
223 tally.done > 0 ? `${tally.done} done` : '',
224 tally.failed > 0 ? `${tally.failed} failed` : '',
225 tally.killed > 0 ? `${tally.killed} stopped` : '',
226 ].filter(part => part !== '')
227
228 return parts.length === 0 ? '' : `agents ${parts.join(' · ')}`
229}
230
231/** `45s`, `3m 07s`, `1h 02m`. */
232export function durationOf(ms: number): string {
233 const seconds = Math.max(0, Math.floor(ms / 1000))
234
235 if (seconds < 60) {
236 return `${seconds}s`
237 }
238
239 const minutes = Math.floor(seconds / 60)
240
241 if (minutes < 60) {
242 return `${minutes}m ${String(seconds % 60).padStart(2, '0')}s`
243 }
244
245 return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`
246}
247
248/** How long a subagent ran, or has been running by `now`. */
249export function elapsedOf(row: AgentRow, now: number): string {
250 return durationOf((row.endedAt ?? Math.max(now, row.startedAt)) - row.startedAt)
251}
252
253/** The mark drawn before a subagent's row. */
254export function markOf(status: AgentStatus): string {
255 switch (status) {
256 case 'running':
257 return '●'
258 case 'done':
259 return '✓'
260 case 'failed':
261 return '✗'
262 default:
263 return '■'
264 }
265}
266
267/** One subagent's row as text: mark, type, description, status, elapsed, tool calls, last action. */
268export function rowText(row: AgentRow, depth: number, now: number): string {
269 const indent = depth === 0 ? '' : `${' '.repeat(depth - 1)}└ `
270 const calls = `${row.toolCalls} tool${row.toolCalls === 1 ? '' : 's'}`
271 const last = row.lastAction === '' ? '' : ` ${row.lastAction}`
272
273 return `${indent}${markOf(row.status)} ${row.type}: ${oneLine(row.description, 40)} ${row.status} ${elapsedOf(row, now)} ${calls}${last}`
274}
275hooks/view.ts 117 lines1/**
2 * What the pane and the status line say. Pure logic: nothing here touches `$`.
3 * The subagent rows themselves come from `tracker.ts`.
4 */
5import type { AgentRow, TaskItem } from '../types'
6import { elapsedOf, markOf, oneLine, tallyOf } from './tracker'
7
8/** Narrowest body the lines are laid out for. */
9const MIN_WIDTH = 24
10
11/** `left` and `right` on one line of `width` cells, `left` cut to make room. */
12export function spread(left: string, right: string, width: number): string {
13 const room = Math.max(MIN_WIDTH, width)
14 const gap = right === '' ? 0 : 2
15 const cut = left.length + gap + right.length > room ? oneLine(left, Math.max(1, room - gap - right.length)) : left
16
17 return right === '' ? cut : `${cut}${' '.repeat(room - cut.length - right.length)}${right}`
18}
19
20/** The indent of a subagent at `depth` (0 for one the main loop spawned). */
21function indentOf(depth: number): string {
22 return ' '.repeat(depth)
23}
24
25/** A subagent's row: mark and description, elapsed time on the right. */
26export function agentLine(row: AgentRow, depth: number, now: number, width: number): string {
27 return spread(`${indentOf(depth)}${markOf(row.status)} ${oneLine(row.description, 200)}`, elapsedOf(row, now), width)
28}
29
30/** The line under a running subagent: its last tool call; '' when it has made none. */
31export function actionLine(row: AgentRow, depth: number, width: number): string {
32 if (row.lastAction === '') {
33 return ''
34 }
35
36 return spread(`${indentOf(depth)} └ ${row.lastAction}`, '', width)
37}
38
39/** The mark of a task: ○ pending, ▸ in progress, ✓ done. */
40export function taskMark(status: TaskItem['status']): string {
41 if (status === 'completed') {
42 return '✓'
43 }
44
45 return status === 'in_progress' ? '▸' : '○'
46}
47
48export function taskLine(task: TaskItem, width: number): string {
49 return spread(`${taskMark(task.status)} ${oneLine(task.subject, 200)}`, '', width)
50}
51
52/** Done and total of the task list. */
53export function countsOf(tasks: readonly TaskItem[]): { done: number; total: number } {
54 return { done: tasks.filter(task => task.status === 'completed').length, total: tasks.length }
55}
56
57/** The task section's header: `Tasks` with done/total on the right. */
58export function tasksHeader(tasks: readonly TaskItem[], width: number): string {
59 const { done, total } = countsOf(tasks)
60
61 return spread('Tasks', `${done}/${total}`, width)
62}
63
64/** The agents section's header: `Agents` with `2 running · 1 done` on the right. */
65export function agentsHeader(rows: readonly AgentRow[], width: number): string {
66 const tally = tallyOf(rows)
67 const parts = [
68 tally.running > 0 ? `${tally.running} running` : '',
69 tally.done > 0 ? `${tally.done} done` : '',
70 tally.failed > 0 ? `${tally.failed} failed` : '',
71 tally.killed > 0 ? `${tally.killed} stopped` : '',
72 ].filter(part => part !== '')
73
74 return spread('Agents', parts.join(' · '), width)
75}
76
77/** Whether there is work under way: a subagent running or a task not done. */
78export function isActive(rows: readonly AgentRow[], tasks: readonly TaskItem[]): boolean {
79 return rows.some(row => row.status === 'running') || tasks.some(task => task.status !== 'completed')
80}
81
82/**
83 * The status line: `agents 2 running · tasks 3/7`. The agents part shows while
84 * one runs, the tasks part while one is not done; '' when neither.
85 */
86export function statusText(rows: readonly AgentRow[], tasks: readonly TaskItem[]): string {
87 const running = tallyOf(rows).running
88 const { done, total } = countsOf(tasks)
89 const parts = [
90 running > 0 ? `agents ${running} running` : '',
91 done < total ? `tasks ${done}/${total}` : '',
92 ].filter(part => part !== '')
93
94 return parts.join(' · ')
95}
96
97/** The task list after a TodoWrite: the whole list, numbered from 1. */
98export function fromTodos(todos: ReadonlyArray<{ content: string; status: TaskItem['status'] }>): TaskItem[] {
99 return todos.map((item, index) => ({ id: String(index + 1), subject: item.content, status: item.status }))
100}
101
102/** The task list after a TaskUpdate. */
103export function updatedTasks(
104 tasks: readonly TaskItem[],
105 change: { taskId: string; subject?: string; status?: TaskItem['status'] | 'deleted' },
106): TaskItem[] {
107 const status = change.status
108
109 if (status === 'deleted') {
110 return tasks.filter(task => task.id !== change.taskId)
111 }
112
113 return tasks.map(task =>
114 task.id === change.taskId ? { ...task, subject: change.subject ?? task.subject, status: status ?? task.status } : task,
115 )
116}
117types/index.d.ts 42 lines1// ---- Subagent tracker (kept identical to mission-control/types/index.d.ts and audit-flow's) ----
2
3/** Where a subagent stands: still working, answered, ended in an error, or stopped. */
4export type AgentStatus = 'running' | 'done' | 'failed' | 'killed'
5
6/** One subagent of the session, as the tracker keeps it. */
7export type AgentRow = {
8 id: string
9 description: string
10 /** The agent definition it runs as (`Explore`, `general-purpose`, ...). */
11 type: string
12 status: AgentStatus
13 /** The subagent that spawned it; absent when the main loop did. */
14 parentId?: string
15 isBackground: boolean
16 startedAt: number
17 endedAt?: number
18 toolCalls: number
19 /** Its last tool call, on one line. */
20 lastAction: string
21}
22
23// ---- agent-tasks ----
24
25/** One item of Claude's own in-session task list. */
26export type TaskItem = {
27 id: string
28 subject: string
29 status: 'pending' | 'in_progress' | 'completed'
30}
31
32declare module 'claude-code' {
33 interface PluginState {
34 'agent-tasks': {
35 agents: AgentRow[]
36 /** The clock as of the last tick, for elapsed times. */
37 now: number
38 tasks: TaskItem[]
39 }
40 }
41}
42