Shows the progress of a delegate run of the repository: a status line entry, toasts, and a /delegate pane, from `delegate status --json`.

This plugin shows the progress of a delegate run of the repository in Claude Code. It holds no progress logic. Every 5 seconds, it runs delegate status --json in the working directory of the session, and it shows that snapshot. It runs no other delegate command, so Claude Code and delegate status show the same numbers.
running, the entry shows the short run id, the percentage, the ETA, the running ticket and its phase, the time since the last activity, and the two token counts: delegate 1638 · 84% · ETA 13:12 · ▶ 60 specialist · active 18s ago · 1.9M new · 53.0M cache
In the gates phase, the time since the last activity is the time since the last output of the gate.
stall_minutes minutes, the entry shows ⚠ idle 6m40s in place of active 18s ago. A band above the prompt then shows the warning in the warning colour of the theme. The status line of Claude Code shows text only, so the colour is on the band.delegate 1638: ticket 60 built), and when the state of the run changes from running to another state (delegate 1638: the run ended, built). A pause request also gives this toast, with the state pausing. The tickets that ended before the first poll of the session give no toast./delegate command opens a pane. Its first line gives the run id, the state, the percentage, the ETA and the tokens of the run. Under it, the pane has one row for each ticket, in plan order: the plan index, the ticket id, the state, the phase, the time used, the tokens, and after <id>. The rows have no tree prefix. The after cell comes from the plan field of the snapshot. When the one blocker of a ticket is the ticket of the row above, the cell is empty. Otherwise, it names each blocker, as after 90 or after 82 89. A blocker that is not in the plan does not count. With no plan (a run of an earlier version), no row has an after cell. The rows of the tickets that ended are dim. The pane changes at each poll. run 20261017T185517Z-1638 · running · 84% · ETA 13:12 · 1.9M new · 53.0M cache
1 ticket 90 built - 57m22s 259k new · 10.9M cache
2 ticket 92 built - 9m42s 112k new · 1.9M cache
3 ticket 84 built - 25m31s 144k new · 2.9M cache after 90
4 ticket 60 running specialist 3m29s 76k new · 1.0M cache
When the snapshot has no run in the state running, the plugin shows no status line entry, and /delegate opens no pane. This is so for a run that ended, and also for a run that is pausing or paused. When more than one run is active, delegate status --json exits 2, and the plugin shows nothing. When the command fails, or its output is not JSON with a percent field (an old version of delegate), the plugin shows nothing and gives no error.
| Option | Default | What it does |
|---|---|---|
command | delegate | The delegate command. The plugin runs <command> status --json. Set a full path when the delegate on the PATH is an old version. |
stall_minutes | 5 | The minutes with no agent activity before the warning shows. |
Set the options in /config, or in pluginConfigs.delegate.options of the settings.
The root of this repository is a plugin marketplace: .claude-plugin/marketplace.json lists this plugin as delegate. In the root of a clone, run:
claude plugin marketplace add ./
claude plugin install delegate@delegate
To try a working copy for one session, run claude --plugin-dir plugins/claude-code.
claude plugin validate plugins/claude-code
claude plugin test plugins/claude-code
The tests in tests/ give the plugin a delegate status --json snapshot that was recorded during a real run (tests/snapshot.ts), and assert the status line entry, the toasts, the band, and the pane.
hooks/register.tsx 273 lines1// The delegate plugin: it shows the progress of a delegate run of the repository in Claude Code.
2//
3// The plugin holds no progress logic. On a timer, it runs `<command> status --json` in the working directory of the
4// session, and shows that snapshot: a status line entry, a warning band, toasts, and the pane of the `/delegate`
5// command. It runs no other delegate command. When the command fails, or its output is not a snapshot with a
6// `percent` field (an old version of delegate), the plugin shows nothing and reports no error.
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import type { DelegateSnapshot, DelegateTicket } from '../types'
12
13const PANE = 'delegate'
14const POLL_MS = 5000
15const COMMAND_TIMEOUT_MS = 20000
16const RUNNING = 'running'
17const GATES = 'gates'
18// The states of a ticket that ended, as `delegate status --json` gives them.
19const ENDED = ['built', 'failed', 'skipped']
20
21const snapshotAtom = atom({ plugin: 'delegate', key: 'snapshot' } as const, null)
22
23// A token count in a short form: `950`, `26k`, or `1.2M`, as `delegate status` shows it.
24function tokens(count: number): string {
25 if (count < 1000) return String(count)
26 if (count < 1_000_000) return `${Math.round(count / 1000)}k`
27 return `${(count / 1_000_000).toFixed(1)}M`
28}
29
30// A field that holds a number, or null. An old version of delegate can give no field, or a field of another type.
31function numberOrNull(value: unknown): number | null {
32 return typeof value === 'number' && Number.isFinite(value) ? value : null
33}
34
35// The two token counts: `302k new · 9.7M cache`, or null when the snapshot has no such counts (a version before #86).
36function tokenCounts(item: { tokens_new: number | null; tokens_cache: number | null }): string | null {
37 const fresh = numberOrNull(item.tokens_new)
38 const cache = numberOrNull(item.tokens_cache)
39 if (fresh === null || cache === null) return null
40 return `${tokens(fresh)} new · ${tokens(cache)} cache`
41}
42
43// A duration in a short form: `45s`, `12m05s`, `2h03m`, or `3d04h`, as `delegate status` shows it.
44function duration(seconds: number): string {
45 const two = (n: number) => String(Math.floor(n)).padStart(2, '0')
46 if (seconds < 60) return `${seconds}s`
47 if (seconds < 3600) return `${Math.floor(seconds / 60)}m${two(seconds % 60)}s`
48 if (seconds < 86400) return `${Math.floor(seconds / 3600)}h${two((seconds % 3600) / 60)}m`
49 return `${Math.floor(seconds / 86400)}d${two((seconds % 86400) / 3600)}h`
50}
51
52// The ETA as a clock time in the local time of the session: `14:52`.
53function clockTime(iso: string): string {
54 const date = new Date(iso)
55 return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
56}
57
58// The short id of a run: the part after the last `-` of `20261017T185517Z-1638`.
59function shortId(runId: string): string {
60 return runId.slice(runId.lastIndexOf('-') + 1)
61}
62
63// The seconds since the last activity of a running ticket: of its gate in the gates phase, else of its agent.
64function idleSeconds(ticket: DelegateTicket): number | null {
65 return numberOrNull(ticket.phase === GATES ? ticket.gate_idle_seconds : ticket.idle_seconds)
66}
67
68// The blockers that the `after` cell of each ticket names, from the `plan` field of the snapshot, as in the `after`
69// column of `delegate watch`. The tickets are in plan order. A blocker that is not in the plan does not count. When the
70// one blocker of a ticket is the ticket of the row above, the cell names no blocker. With no plan (a journal of an
71// earlier version, or a version of delegate before #120), no ticket has a blocker.
72function afterIds(snap: DelegateSnapshot): Map<string, string[]> {
73 const blockers = new Map<string, string[]>()
74 for (const entry of Array.isArray(snap.plan) ? snap.plan : []) {
75 if (Array.isArray(entry?.blocked_by)) blockers.set(String(entry.id), entry.blocked_by.map(String))
76 }
77 const known = new Set(snap.tickets.map(ticket => ticket.id))
78 const found = new Map<string, string[]>()
79 snap.tickets.forEach((ticket, index) => {
80 const own = (blockers.get(ticket.id) ?? []).filter(blocker => known.has(blocker))
81 const joined = index > 0 && own.length === 1 && own[0] === snap.tickets[index - 1].id
82 found.set(ticket.id, joined ? [] : own)
83 })
84 return found
85}
86
87function runningTickets(snap: DelegateSnapshot): DelegateTicket[] {
88 return snap.tickets.filter(ticket => ticket.state === RUNNING)
89}
90
91// The first running ticket when its activity stopped for `limitSeconds` or more, else null.
92function stalledTicket(snap: DelegateSnapshot, limitSeconds: number): DelegateTicket | null {
93 const ticket = runningTickets(snap)[0]
94 if (ticket === undefined) return null
95 const idle = idleSeconds(ticket)
96 return idle !== null && idle >= limitSeconds ? ticket : null
97}
98
99function statusText(snap: DelegateSnapshot, limitSeconds: number): string {
100 const parts = [`delegate ${shortId(snap.run_id)}`, `${snap.percent}%`]
101 if (snap.eta !== null) parts.push(`ETA ${clockTime(snap.eta)}`)
102 const running = runningTickets(snap)
103 const ticket = running[0]
104 if (ticket !== undefined) {
105 const more = running.length > 1 ? ` +${running.length - 1}` : ''
106 parts.push(`▶ ${ticket.id}${ticket.phase ? ` ${ticket.phase}` : ''}${more}`)
107 const idle = idleSeconds(ticket)
108 if (idle !== null) parts.push(idle >= limitSeconds ? `⚠ idle ${duration(idle)}` : `active ${duration(idle)} ago`)
109 } else if (snap.phase) {
110 parts.push(`▶ ${snap.phase}`)
111 }
112 const counts = tokenCounts(snap)
113 if (counts !== null) parts.push(counts)
114 return parts.join(' · ')
115}
116
117// The snapshot in the output of the command, or null when the output is not a snapshot that has a `percent` field.
118function parse(stdout: string): DelegateSnapshot | null {
119 let value: unknown
120 try {
121 value = JSON.parse(stdout)
122 } catch {
123 return null
124 }
125 if (typeof value !== 'object' || value === null) return null
126 const snap = value as Partial<DelegateSnapshot>
127 if (typeof snap.percent !== 'number' || typeof snap.run_id !== 'string' || !Array.isArray(snap.tickets)) return null
128 return snap as DelegateSnapshot
129}
130
131// The toasts for the change from the last snapshot to this one: one for each ticket that ended, one when the run ended.
132// A snapshot of a different run, or the first snapshot of the session, gives no toast.
133function toasts(last: DelegateSnapshot | null, snap: DelegateSnapshot | null): string[] {
134 if (last === null || snap === null || last.run_id !== snap.run_id) return []
135 const id = shortId(snap.run_id)
136 const endedBefore = new Set(last.tickets.filter(ticket => ENDED.includes(ticket.state)).map(ticket => ticket.id))
137 const lines = snap.tickets
138 .filter(ticket => ENDED.includes(ticket.state) && !endedBefore.has(ticket.id))
139 .map(ticket => `delegate ${id}: ticket ${ticket.id} ${ticket.state}`)
140 if (last.state === RUNNING && snap.state !== RUNNING) lines.push(`delegate ${id}: the run ended, ${snap.state}`)
141 return lines
142}
143
144// What a poll needs: the options, and what the last poll saw. The last snapshot is a module value: after a reload,
145// the first poll again gives no toast.
146type Poller = { command: string; limitSeconds: number; last: DelegateSnapshot | null; inFlight: Promise<void> | null }
147
148async function fetchSnapshot($: EngineInterface, command: string): Promise<DelegateSnapshot | null> {
149 try {
150 const ran = await $.process.run([command, 'status', '--json'], { timeoutMs: COMMAND_TIMEOUT_MS })
151 return ran.exitCode === 0 ? parse(ran.stdout) : null
152 } catch {
153 return null
154 }
155}
156
157// One poll: run the command, show the toasts of the change, set the status line entry, and keep the snapshot for the
158// band and the pane.
159async function pollOnce($: EngineInterface, poller: Poller): Promise<void> {
160 const snap = await fetchSnapshot($, poller.command)
161 for (const line of toasts(poller.last, snap)) $.ui.toast(line)
162 poller.last = snap
163 const isActive = snap !== null && snap.state === RUNNING
164 $.ui.status(isActive ? statusText(snap, poller.limitSeconds) : undefined)
165 await update($, snapshotAtom, () => snap)
166}
167
168// A poll. While a poll runs, a new call gives that poll and starts no second command, so a caller that waits for the
169// result (the `/delegate` command) sees the snapshot of the poll.
170function poll($: EngineInterface, poller: Poller): Promise<void> {
171 if (poller.inFlight === null) {
172 poller.inFlight = pollOnce($, poller).finally(() => {
173 poller.inFlight = null
174 })
175 }
176 return poller.inFlight
177}
178
179export const register: Register = (on, options) => {
180 const poller: Poller = {
181 command: typeof options.command === 'string' && options.command !== '' ? options.command : 'delegate',
182 limitSeconds: (typeof options.stall_minutes === 'number' ? options.stall_minutes : 5) * 60,
183 last: null,
184 inFlight: null,
185 }
186 const limitSeconds = poller.limitSeconds
187
188 on('session.start', async ($, e, next) => {
189 const started = await next(e)
190 await $.command.register({
191 name: 'delegate',
192 description: 'Show each ticket of the active delegate run in a pane',
193 })
194 // The first poll does not hold the start of the session: the command can take some seconds.
195 void poll($, poller)
196 $.clock.every(POLL_MS, () => poll($, poller))
197 return started
198 })
199
200 on('command.run', { command: 'delegate' }, async $ => {
201 await poll($, poller)
202 const snap = poller.last
203 if (snap === null || snap.state !== RUNNING) return { text: 'No delegate run is active in this directory.' }
204 await $.ui.open({ id: PANE, title: `delegate ${shortId(snap.run_id)}` })
205 return { text: `The pane of run ${shortId(snap.run_id)} is open.` }
206 })
207
208 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
209 const snap = await read($, snapshotAtom)
210 const ticket = snap !== null && snap.state === RUNNING ? stalledTicket(snap, limitSeconds) : null
211 if (snap === null || ticket === null || e.props.hasSurvey) return next(e)
212 const { Box, Text } = $.ui.resolve(e)
213 const idle = duration(idleSeconds(ticket) ?? 0)
214 const phase = ticket.phase ? ` ${ticket.phase}` : ''
215 return (
216 <Box key="stall">
217 <Text color="warning">
218 ⚠ delegate {shortId(snap.run_id)}: no agent activity for {idle} (ticket {ticket.id}{phase})
219 </Text>
220 </Box>
221 )
222 })
223
224 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
225 const { Box, Text } = $.ui.resolve(e)
226 const snap = await read($, snapshotAtom)
227 if (snap === null) {
228 return (
229 <Box>
230 <Text dimColor>No delegate run is active in this directory.</Text>
231 </Box>
232 )
233 }
234 const head = [`run ${snap.run_id}`, snap.state, `${snap.percent}%`]
235 if (snap.state === RUNNING && snap.eta !== null) head.push(`ETA ${clockTime(snap.eta)}`)
236 const counts = tokenCounts(snap)
237 if (counts !== null) head.push(counts)
238 // Each row starts with the plan index of the ticket and its id. When the blocker of the ticket is not the row
239 // above, the row ends with `after <id>`, one id for each blocker.
240 const after = afterIds(snap)
241 const plan = (ticket: DelegateTicket) => `ticket ${ticket.id}`
242 const indexWidth = String(snap.tickets.length).length
243 const planWidth = Math.max(...snap.tickets.map(ticket => plan(ticket).length))
244 const tokensWidth = Math.max(...snap.tickets.map(ticket => (tokenCounts(ticket) ?? '').length))
245 const afterCell = (ticket: DelegateTicket) => {
246 const ids = after.get(ticket.id) ?? []
247 return ids.length === 0 ? '' : `after ${ids.join(' ')}`
248 }
249 const row = (ticket: DelegateTicket, index: number) =>
250 [
251 String(index + 1).padStart(indexWidth),
252 plan(ticket).padEnd(planWidth),
253 ticket.state.padEnd(8),
254 (ticket.phase ?? '-').padEnd(10),
255 duration(numberOrNull(ticket.elapsed_seconds) ?? 0).padEnd(7),
256 (tokenCounts(ticket) ?? '').padEnd(tokensWidth),
257 afterCell(ticket),
258 ].join(' ').trimEnd()
259 return (
260 <Box flexDirection="column">
261 <Box key="run">
262 <Text bold>{head.join(' · ')}</Text>
263 </Box>
264 {snap.tickets.map((ticket, index) => (
265 <Box key={`ticket-${ticket.id}`}>
266 <Text dimColor={ENDED.includes(ticket.state)}>{row(ticket, index)}</Text>
267 </Box>
268 ))}
269 </Box>
270 )
271 })
272}
273types/index.d.ts 40 lines1// The type contract of the delegate plugin: the part of the `delegate status --json` snapshot that it shows, and the
2// value that it keeps in `$.state`.
3
4export type DelegateTicket = {
5 id: string
6 state: string
7 phase: string | null
8 elapsed_seconds: number
9 tokens_new: number | null
10 tokens_cache: number | null
11 idle_seconds: number | null
12 gate: string | null
13 gate_idle_seconds: number | null
14}
15
16// One ticket of the newest plan of the run, with the ids of the tickets that block it.
17export type DelegatePlanTicket = {
18 id: string
19 blocked_by: string[]
20}
21
22export type DelegateSnapshot = {
23 run_id: string
24 state: string
25 percent: number
26 eta: string | null
27 tokens_new: number | null
28 tokens_cache: number | null
29 phase: string | null
30 tickets: DelegateTicket[]
31 // The plan with its blockers (#120). `null` for a journal with no plan; no field in a version before #120.
32 plan?: DelegatePlanTicket[] | null
33}
34
35declare module 'claude-code' {
36 interface PluginState {
37 delegate: { snapshot: DelegateSnapshot | null }
38 }
39}
40