SLOPSHOPPER

delegate

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

panebandcommandtoaststatusprocess
v0.1.0MITupdated 2026-10-22AlexanderNZ/delegate/plugins/claude-code
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · delegate
│ ┃ delegate ✕ › fix the failing auth test and add an audit log call │ ┃ No delegate run is active in this directory. │ ⏺ 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 │ │ › /delegate │ ⎿ delegate: No delegate run is active in this directory. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · delegate
No delegate run is active in this directory.
README

The delegate plugin for Claude Code

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.

What it shows

  • The status line entry. While the run of the snapshot is in the state 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.

  • The warning. When no agent activity occurs for 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.
  • The toasts. A toast shows when a ticket ends (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.
  • The pane. The /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.

Options

OptionDefaultWhat it does
commanddelegateThe delegate command. The plugin runs <command> status --json. Set a full path when the delegate on the PATH is an old version.
stall_minutes5The minutes with no agent activity before the warning shows.

Set the options in /config, or in pluginConfigs.delegate.options of the settings.

Enable it

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.

Test it

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.

Source 2 files
hooks/register.tsx 273 lines
1// 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}
273
types/index.d.ts 40 lines
1// 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