SLOPSHOPPER

dispatch-panel

A side panel listing dispatched agents and background shells, with purpose, tokens and duration

newpaneguardcommandmodeltimer
v1.5.4no licenseupdated 2026-10-16tinkybeatz/tinky-claude-mods/dispatch-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dispatch-panel
│ ┃ Dispatched ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ [ Active ( ] [ Finished ( ] ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Nothing running. ⏺ 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 │ ┃ │ ┃ › /dispatches │ ┃ ⎿ dispatch-panel: Dispatch panel opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Dispatched
[ Active ( ] [ Finished ( ] Nothing running.
README

tinky-claude-mods

Claude Code mods: plugins that change Claude Code's own interface or behaviour through function hooks. This repository holds several mods, one folder each.

ModWhat it does
dispatch-panelA side panel listing dispatched agents and background shells, with their purpose, input and output tokens and duration, in Active and Finished tabs, coloured by what each agent is doing.

godmode (an agentic dev loop for any project) is being built in godmode/. It is not in the marketplace yet.

Installing on a new machine

The repository is a marketplace (.claude-plugin/marketplace.json), so the mods install from GitHub with no clone.

1. Add the marketplace. At a Claude Code prompt:

/plugin marketplace add tinkybeatz/tinky-claude-mods

2. Install a mod. Still at the prompt:

/plugin install dispatch-panel@tinky-claude-mods

Pick the user scope so it loads in every project. The mod runs at once, with no restart.

Use the <mod>@tinky-claude-mods form. /plugin install dispatch-panel --marketplace … is ignored by some versions, which then search the default marketplace and answer Plugin "dispatch-panel" not found.

3. Use the fullscreen layout, so the panel docks on the right. Claude Code draws a panel on the right only in its fullscreen layout, in a window at least 110 columns wide. Otherwise the panel sits above the input bar. The layout is chosen when Claude Code starts, so a mod cannot change it. In a normal terminal, run once:

echo 'export CLAUDE_CODE_NO_FLICKER=1' >> ~/.zshrc

(~/.bashrc for bash.) Then open a new terminal window and start claude. Every new window keeps the setting. Inside tmux, Claude Code uses the other layout by default, so start it outside tmux. A window narrower than 110 columns puts the panel back above the input bar until it is widened.

4. Updating. An installed mod runs the copy made when it was installed. To get a newer version:

claude plugin update dispatch-panel@tinky-claude-mods

Then restart Claude Code (or run /reload-plugins in an open session). /plugin → Installed also has Update now.

When it does not work

What you seeWhyFix
/plugin → Errors: Failed to load hooks from …/hooks.json … expected record, received undefinedThis machine's Claude Code has mods switched off, so it reads hooks.json in the older hooks formatRun claude -p "Reply with the single word ok." --model haiku once so Claude Code refreshes its settings, then quit and restart it. If the error stays, the logged-in account does not have mods yet. claude update if the version is old.
Plugin "dispatch-panel" not found in … marketplaceThe marketplace was not added, or the --marketplace flag was ignoredSteps 1 and 2 above, with the @tinky-claude-mods form.
Adding the marketplace is refusedAn organization's managed settings restrict marketplacesAn admin has to allow it.
The panel is above the input barMain-screen layout, tmux, or a narrow windowStep 3.
Some features never react (on a work login)Where an organization manages Claude Code, some hooks of plugins a person installs are skippedAn admin lists the plugins in the managed settings.

From a local clone (developing)

To run a mod straight from a clone, so every edit is picked up: add its folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json. Separate several folders with :.

"env": {
  "CLAUDE_CODE_PLUGIN_DIRS": "~/Documents/GAEL/AI/tinky-claude-mods/dispatch-panel"
}

New sessions load the mod, and a session reloads it when its files change. A session that was already running when the setting changed does not pick it up.

For one session only:

claude --plugin-dir ~/Documents/GAEL/AI/tinky-claude-mods/dispatch-panel

Do not install the same mod from the marketplace and from a clone at once.

dispatch-panel

The panel opens by itself in a terminal at least 144 columns wide, docked on the right in the fullscreen layout (see step 3 above). Elsewhere, open it with /dispatches.

CommandEffect
/dispatchesOpens the panel.
/dispatches active / finishedOpens the panel on that tab.
/dispatches clearDrops finished rows.
/dispatches clear allDrops every row, active ones included.
/dispatches width <columns>Sets the panel's width, remembered across sessions.

While the panel has focus, a and f switch tabs.

In each tab, rows are grouped into an Agents section and a Shells section; click a section's header to collapse or expand it.

Only work the main thread started gets a card of its own. An agent or shell that a dispatched agent started is listed inside that agent's card as an indented line (├ and └, one step deeper per level), with its own status mark, activity tag ([shell] for a shell) and duration. The card stays in Active while anything in it still runs, and its token line adds sub in and out, the tokens of every agent listed inside it. An agent counts as finished once it hands back its result (the engine marks it idle), and returns to Active if a message wakes it again. A teammate that is idle is waiting for a message, not done, so it stays in Active with a ◌ mark.

Each row's title is its purpose (the description the agent or shell was given), followed by its activity tag and how long it has run. Below it, an agent's type appears only when it is not a catch-all one (general-purpose, claude), such as Explore or Plan. Agents also show what they have consumed: in, the input tokens of all their requests added up (cache reads and writes included, since each request sends the whole context again), and out, all the output tokens they have generated.

The panel's footer shows the account's usage limits as bars, one per window the last API response reported (Session (5h), Week, or a gateway's spend limit), each with the percentage used and the time until it resets. A bar is green, then yellow from 70% and red from 90%. Off a subscription, or before the first response, no window has a reading and the footer is hidden.

A running agent's card ends with a strip naming what it is doing right now, read from its latest tool call: reading register.tsx, running tests, committing, updating Jira, and thinking between calls. Shell commands are matched against a list of known ones (git, gh, test runners, linters, …); any other command shows the description the agent gave it.

Each agent's colour shows what it is doing, not its type: a general-purpose agent writing code and one updating Jira get different colours. When an agent starts, a small fast model reads its type, description and the start of its prompt, and picks one activity (one model call per agent). A description that names its activity outright ("Update PROD-4882 in Jira", "Open a PR for the fix") is coloured at once, with no model call, and the model cannot overrule it. The card shows that activity's short tag in brackets after the agent's type, such as general-purpose [research], so two agents share a colour only when they do the same kind of work. The activities, tags and colours are in dispatch-panel/hooks/kinds.ts.

ActivityTagColour
writing or changing codecodeblue
reviewing or testingreviewred
updating tickets (Jira, issues)jiracyan
git, branches or pull requestsgitpink
searching or researchingresearchyellow
planning or designingplangreen
writing docs or textdocswhite
configuring or setting upsetuppurple
analysing datadatalime
something elseotherorange

Shells, and agents not classified yet (the first second or so), are slate grey and have no tag.

A failed row keeps its activity's border and shows a red ✗.

A background shell's row ends when its completion notification arrives, with the status that notification reports (done, failed or killed), including shells an agent started.

Known limit: workflow agents are not listed.

Developing

Each mod is a folder with .claude-plugin/plugin.json, hooks/hooks.json, its hooks module and, when it keeps state, a types/index.d.ts contract. From a mod's folder:

claude plugin validate .
claude plugin test .
tsc -p .

tsc -p . needs the type declarations the engine lays in .claude-plugin/types/ when it loads the mod; they are generated, so they are not committed.

Source 5 files
hooks/register.tsx 659 lines
1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, Register } from 'claude-code'
3
4import type { Dispatch, DispatchKind, DispatchTab, UsageLimit } from '../types'
5import { THINKING, describeAction, shellTitle } from './actions'
6import {
7  ACTIVITY_LABELS,
8  CLASSIFIER_HINTS,
9  FAILED_MARK_COLOR,
10  activityColor,
11  activityFromDescription,
12  activityTag,
13} from './kinds'
14import { buildTree, descendantsOf, dropFinishedTrees, liveRootIds } from './tree'
15import type { Descendant } from './tree'
16
17const PANE = 'dispatches'
18const TITLE = 'Dispatched'
19const MAX_ROWS = 100
20const OUTER_X = 2
21const OUTER_Y = 1
22const INNER_X = 1
23const ROW_GAP = 0
24const ACTION_BACKGROUND = '#3a3a3a'
25const WIDTH_SHARE = 2 / 3
26const MIN_COLUMNS = 24
27const COLUMNS_KEY = 'columns'
28/** The footer's name for each rate-limit window; any other kind shows its own name. */
29const LIMIT_NAMES: Readonly<Record<string, string>> = { five_hour: 'Session (5h)', seven_day: 'Week', spend_limit: 'Spend' }
30const LIMIT_EMPTY_COLOR = '#3a3a3a'
31/** A bar turns yellow, then red, as its window fills. */
32const limitColor = (percent: number) => (percent >= 90 ? '#ef4444' : percent >= 70 ? '#facc15' : '#22c55e')
33/** Catch-all agent types: the card's subtitle names an agent's type only when it is not one of these. */
34const GENERIC_TYPES = new Set(['general-purpose', 'claude', 'agent'])
35
36const ENDED = new Set(['completed', 'failed', 'killed', 'done'])
37
38/**
39 * The panel's status for an agent the engine lists. A subagent that handed back its
40 * result is left `idle`: it could be woken again, but its work is done, so it counts as
41 * finished, and a message that wakes it turns it back to running. A teammate that is
42 * `idle` is waiting for a message, not done, so it keeps `idle` and stays in Active.
43 */
44const panelStatus = (agent: AgentInfo) => {
45  const isTeammate = agent.teammateId !== undefined || agent.type === 'teammate'
46  return agent.status === 'idle' && !isTeammate ? 'done' : agent.status
47}
48
49const dispatches = atom({ plugin: 'dispatch-panel', key: 'dispatches' } as const, [])
50const now = atom({ plugin: 'dispatch-panel', key: 'now' } as const, 0)
51const tab = atom({ plugin: 'dispatch-panel', key: 'tab' } as const, 'active' as DispatchTab)
52const isSized = atom({ plugin: 'dispatch-panel', key: 'isSized' } as const, false)
53const limits = atom({ plugin: 'dispatch-panel', key: 'limits' } as const, [] as UsageLimit[])
54const collapsed = atom({ plugin: 'dispatch-panel', key: 'collapsed' } as const, { agent: false, shell: false } as Record<DispatchKind, boolean>)
55
56/** The docked pane's width as last drawn; the render hook records it, since no `$` call reports it. */
57let measuredColumns: number | undefined
58
59const isRunning = (row: Dispatch) => !ENDED.has(row.status)
60
61/** A task-notification row's id and status, in the order the engine writes them. */
62const TASK_NOTIFICATION = /<task-id>([^<]+)<\/task-id>[\s\S]*?<status>([^<]+)<\/status>/g
63/** The panel's status for each status a task notification reports. */
64const NOTIFIED_STATUS: Readonly<Record<string, string>> = { completed: 'done', failed: 'failed', killed: 'killed' }
65
66const firstLine = (text: string) => text.split('\n')[0]?.trim() ?? ''
67
68const upsert = ($: EngineInterface, id: string, patch: (row: Dispatch | undefined) => Dispatch | undefined) =>
69  update($, dispatches, list => {
70    const rows = list ?? []
71    const found = rows.find(row => row.id === id)
72    const next = patch(found)
73    if (next === undefined) return rows
74    if (found === undefined) return [...rows, next].slice(-MAX_ROWS)
75    return rows.map(row => (row.id === id ? next : row))
76  })
77
78/** Ends each tracked row a task notification in `raw` reports on, with the status it reports. */
79const applyNotifications = async ($: EngineInterface, raw: string) => {
80  if (!raw.includes('<task-notification>')) return
81  const at = await $.clock.now()
82  for (const match of raw.matchAll(TASK_NOTIFICATION)) {
83    const [, id, status] = match
84    if (id === undefined || status === undefined) continue
85    const ended = NOTIFIED_STATUS[status] ?? 'done'
86    await upsert($, id, row =>
87      row === undefined || (!isRunning(row) && row.status !== 'done')
88        ? undefined
89        : { ...row, status: ended, endedAt: row.endedAt ?? at },
90    )
91  }
92}
93
94const formatCount = (count: number) => {
95  if (count < 1000) return `${count}`
96  if (count < 1_000_000) return `${(count / 1000).toFixed(1)}k`
97  return `${(count / 1_000_000).toFixed(2)}M`
98}
99
100const storedColumns = async ($: EngineInterface) => {
101  const stored = await $.store.get(COLUMNS_KEY)
102  return typeof stored === 'number' ? stored : undefined
103}
104
105const openPane = async ($: EngineInterface) => {
106  const columns = await storedColumns($)
107  return $.ui.open({ id: PANE, title: TITLE, ...(columns === undefined ? {} : { columns }) })
108}
109
110/**
111 * Narrows the dock once per session to the remembered width, or to two thirds of the
112 * width it was first drawn at, and remembers that width for later sessions.
113 * The dock treats it as a request: a width the person dragged the dock to wins.
114 */
115const fitWidth = async ($: EngineInterface) => {
116  if (measuredColumns === undefined || (await read($, isSized)) === true) return
117  const panes = await $.ui.panes()
118  if (!panes.some(pane => pane.id === PANE && pane.isPlaced)) return
119  const columns = (await storedColumns($)) ?? Math.max(MIN_COLUMNS, Math.round(measuredColumns * WIDTH_SHARE))
120  await $.store.set(COLUMNS_KEY, columns)
121  await update($, isSized, () => true)
122  await $.ui.open({ id: PANE, title: TITLE, columns })
123}
124
125const formatDuration = (ms: number) => {
126  const seconds = Math.max(0, Math.floor(ms / 1000))
127  const h = Math.floor(seconds / 3600)
128  const m = Math.floor((seconds % 3600) / 60)
129  const s = seconds % 60
130  if (h > 0) return `${h}h${String(m).padStart(2, '0')}m`
131  if (m > 0) return `${m}m${String(s).padStart(2, '0')}s`
132  return `${s}s`
133}
134
135/** Time left until an ISO timestamp, as `2h14m` or `3d04h`; empty when unknown or past. */
136const formatUntil = (iso: string | undefined, at: number) => {
137  const resets = iso === undefined ? Number.NaN : Date.parse(iso)
138  if (Number.isNaN(resets) || resets <= at) return ''
139  const minutes = Math.ceil((resets - at) / 60_000)
140  const d = Math.floor(minutes / 1440)
141  const h = Math.floor((minutes % 1440) / 60)
142  const m = minutes % 60
143  if (d > 0) return `${d}d${String(h).padStart(2, '0')}h`
144  if (h > 0) return `${h}h${String(m).padStart(2, '0')}m`
145  return `${m}m`
146}
147
148/**
149 * Reads the account's rate-limit windows, as the status line has them. The plain
150 * `$.session.usage()` call costs nothing; the atom is written only when a window moved.
151 */
152const syncLimits = async ($: EngineInterface) => {
153  const { rateLimits } = await $.session.usage()
154  const fresh = rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt }))
155  const known = (await read($, limits)) ?? []
156  if (JSON.stringify(fresh) !== JSON.stringify(known)) await update($, limits, () => fresh)
157}
158
159const statusMark = (status: string) => {
160  if (status === 'failed') return '✗'
161  if (status === 'killed') return '■'
162  if (status === 'idle' || status === 'waiting') return '◌'
163  if (ENDED.has(status)) return '✓'
164  return '●'
165}
166
167/** Reconciles tracked agents with the engine's own list, and finds any the spawn hook missed. */
168const syncAgents = async ($: EngineInterface, at: number) => {
169  const agents = await $.agent.list()
170  for (const agent of agents) {
171    const status = panelStatus(agent)
172    await upsert($, agent.id, row => {
173      if (row === undefined) {
174        if (ENDED.has(status)) return undefined
175        return {
176          id: agent.id,
177          kind: 'agent',
178          label: agent.type,
179          purpose: agent.description,
180          status,
181          startedAt: at,
182          parentId: agent.parentId,
183          ...(activityFromDescription(agent.description) === undefined
184            ? {}
185            : { activity: activityFromDescription(agent.description), isClassified: true }),
186        }
187      }
188      const parentId = row.parentId ?? agent.parentId
189      if (row.status === status && row.parentId === parentId) return undefined
190      const hasEnded = ENDED.has(status)
191      return {
192        ...row,
193        status,
194        parentId,
195        endedAt: hasEnded ? (row.endedAt ?? at) : undefined,
196      }
197    })
198  }
199  // An agent the engine no longer lists is over, whatever its row last said:
200  // without this, a row whose final status was never seen stays active forever.
201  const listed = new Set(agents.map(agent => agent.id))
202  await update($, dispatches, list =>
203    (list ?? []).map(row =>
204      row.kind === 'agent' && isRunning(row) && !listed.has(row.id) ? { ...row, status: 'done', endedAt: at } : row,
205    ),
206  )
207}
208
209/**
210 * Ends the running shells of an agent that has ended. Such a shell sends its completion
211 * notice to its owner's queue, which nobody reads once the owner is done or stopped, so
212 * without this its row stays active forever. A stopped owner's shells show as stopped.
213 */
214const endOrphanShells = async ($: EngineInterface, at: number) =>
215  update($, dispatches, list => {
216    const rows = list ?? []
217    const byId = new Map(rows.map(row => [row.id, row]))
218    return rows.map(row => {
219      const owner = row.parentId === undefined ? undefined : byId.get(row.parentId)
220      if (row.kind !== 'shell' || !isRunning(row) || owner === undefined || isRunning(owner)) return row
221      return { ...row, status: owner.status === 'killed' ? 'killed' : 'done', endedAt: owner.endedAt ?? at }
222    })
223  })
224
225/**
226 * Classifies one agent's activity per tick, outside any hook's dispatch, so a slow
227 * model call never holds up a spawn. A failed call settles on 'something else'.
228 */
229const classifyNext = async ($: EngineInterface) => {
230  const list = (await read($, dispatches)) ?? []
231  const row = list.find(one => one.kind === 'agent' && one.isClassified !== true)
232  if (row === undefined) return
233  await upsert($, row.id, found => (found === undefined ? undefined : { ...found, isClassified: true }))
234  const text = [`agent type: ${row.label}`, `task: ${row.purpose}`, row.brief ? `prompt: ${row.brief}` : '', CLASSIFIER_HINTS]
235    .filter(Boolean)
236    .join('\n')
237  let activity: string | undefined
238  try {
239    activity = await $.model.classify(text, ACTIVITY_LABELS)
240  } catch {
241    activity = undefined
242  }
243  await upsert($, row.id, found =>
244    found === undefined ? undefined : { ...found, activity: activity ?? 'something else' },
245  )
246}
247
248const tick = async ($: EngineInterface) => {
249  const at = await $.clock.now()
250  await syncAgents($, at)
251  await endOrphanShells($, at)
252  await classifyNext($)
253  await fitWidth($)
254  await syncLimits($).catch(() => undefined)
255  const list = (await read($, dispatches)) ?? []
256  if (list.some(isRunning)) await update($, now, () => at)
257}
258
259export const register: Register = on => {
260  on('session.start', async ($, e, next) => {
261    await $.command.register({
262      name: 'dispatches',
263      description:
264        'Show dispatched agents and shells in a side panel (`active`, `finished`, `clear` to drop finished rows, `clear all` to drop every row, `width <columns>` to resize)',
265      argumentHint: '[active|finished|clear|clear all|width <columns>]',
266    })
267    const startedAt = await $.clock.now()
268    await update($, now, () => startedAt)
269    $.clock.every(1000, () => void tick($))
270    void openPane($)
271
272    return next(e)
273  })
274
275  on('command.run', { command: 'dispatches' }, async ($, e) => {
276    const arg = e.args.trim()
277    if (arg === 'active' || arg === 'finished') {
278      await update($, tab, () => arg)
279      await openPane($)
280      return { text: `Dispatch panel opened on ${arg}.` }
281    }
282    const width = /^width\s+(\d+)$/.exec(arg)
283    if (width?.[1] !== undefined) {
284      const columns = Math.max(MIN_COLUMNS, Number(width[1]))
285      await $.store.set(COLUMNS_KEY, columns)
286      await update($, isSized, () => true)
287      await $.ui.open({ id: PANE, title: TITLE, columns })
288      return { text: `Dispatch panel width set to ${columns} columns.` }
289    }
290    if (arg === 'clear all') {
291      await update($, dispatches, () => [])
292      return { text: 'Cleared every dispatch, active ones included.' }
293    }
294    if (arg === 'clear') {
295      await update($, dispatches, list => dropFinishedTrees(list ?? [], isRunning))
296      return { text: 'Cleared finished dispatches.' }
297    }
298    await openPane($)
299    return { text: 'Dispatch panel opened.' }
300  })
301
302  on('agent.spawn', async ($, e, next) => {
303    const startedAt = await $.clock.now()
304    const spawned = await next(e)
305    if (spawned.agentId !== undefined) {
306      const agentId = spawned.agentId
307      // A description that names its activity outright colours the row at once.
308      const signalled = activityFromDescription(e.description)
309      await upsert($, agentId, row => ({
310        id: agentId,
311        kind: 'agent',
312        label: e.subagentType || 'agent',
313        purpose: e.description || firstLine(e.prompt),
314        brief: e.prompt.slice(0, 600),
315        activity: row?.activity ?? signalled,
316        isClassified: row?.isClassified ?? (signalled === undefined ? undefined : true),
317        action: row?.action ?? 'starting',
318        status: row?.status ?? 'running',
319        startedAt: row?.startedAt ?? startedAt,
320        endedAt: row?.endedAt,
321        inputTokens: row?.inputTokens,
322        outputTokens: row?.outputTokens,
323        parentId: e.parentAgentId ?? row?.parentId,
324      }))
325    }
326    return spawned
327  })
328
329  on('turn.step', async function* ($, e, next) {
330    const step = yield* next(e)
331    const agentId = e.agentId
332    const usage = step.usage
333    if (agentId !== undefined && usage !== null) {
334      const inputTokens = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
335      await upsert($, agentId, row =>
336        row === undefined
337          ? undefined
338          : {
339              ...row,
340              inputTokens: (row.inputTokens ?? 0) + inputTokens,
341              outputTokens: (row.outputTokens ?? 0) + usage.output_tokens,
342            },
343      )
344    }
345    return step
346  })
347
348  /** Each tool call an agent makes is its current action, until the call ends and it thinks again. */
349  on('tool.call', async ($, e, next) => {
350    const agentId = e.agentId
351    if (agentId === undefined) return next(e)
352    const action = describeAction(String(e.tool), e as unknown as Record<string, unknown>)
353    const setAction = (label: string) =>
354      upsert($, agentId, row => (row === undefined || !isRunning(row) ? undefined : { ...row, action: label }))
355    await setAction(action)
356    const ran = await next(e)
357    await setAction(THINKING)
358    return ran
359  })
360
361  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
362    if (e.tool !== 'Bash') return next(e)
363    const startedAt = await $.clock.now()
364    const ran = await next(e)
365    if (ran.deny !== undefined || ran.isError) return ran
366    const taskId = ran.result.backgroundTaskId
367    if (taskId !== undefined) {
368      const purpose = e.description || shellTitle(e.command)
369      await upsert($, taskId, row =>
370        row ?? { id: taskId, kind: 'shell', label: 'bash', purpose, status: 'running', startedAt, parentId: e.agentId },
371      )
372    }
373    return ran
374  })
375
376  on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
377    if (e.tool !== 'TaskStop') return next(e)
378    const ran = await next(e)
379    const taskId = e.task_id ?? e.shell_id
380    if (taskId !== undefined && ran.deny === undefined && !ran.isError) {
381      const at = await $.clock.now()
382      await upsert($, taskId, row =>
383        row === undefined || !isRunning(row) ? undefined : { ...row, status: 'killed', endedAt: at },
384      )
385    }
386    return ran
387  })
388
389  /**
390   * Every finished background task sends a notification to its owner, a subagent
391   * included, carrying the task's id and final status. It is the one signal that
392   * reaches the panel for a shell a subagent started.
393   *
394   * It reaches the session twice: `session.receive` when it is queued, the moment
395   * the task ends, and `session.append` once it becomes a row of the conversation.
396   * Between the person's turns the main thread's notifications wait in the queue
397   * until the next turn, and some are folded into that turn without a row of their
398   * own, so the queue is where a shell that ends between turns is seen.
399   */
400  on('session.receive', async ($, e, next) => {
401    await applyNotifications($, e.text)
402    return next(e)
403  })
404
405  on('session.append', async ($, e, next) => {
406    const appended = await next(e)
407    await applyNotifications($, JSON.stringify(e.message.content))
408    return appended
409  })
410
411  /** At each stop the engine lists the background work still in flight: any shell missing from it has finished. */
412  on('classic.Stop', async ($, e, next) => {
413    const inFlight = e.background_tasks ?? []
414    const at = await $.clock.now()
415    const liveShells = new Set(inFlight.filter(task => task.type === 'shell').map(task => task.id))
416    await update($, dispatches, list =>
417      (list ?? []).map(row =>
418        row.kind === 'shell' && isRunning(row) && !liveShells.has(row.id)
419          ? { ...row, status: 'done', endedAt: at }
420          : row,
421      ),
422    )
423    for (const task of inFlight) {
424      if (task.type !== 'shell') continue
425      await upsert($, task.id, row =>
426        row ?? {
427          id: task.id,
428          kind: 'shell',
429          label: 'bash',
430          purpose: task.description || shellTitle(task.command ?? ''),
431          status: 'running',
432          startedAt: at,
433        },
434      )
435    }
436    return next(e)
437  })
438
439  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
440    const { Box, Button, Text } = $.ui.resolve(e)
441    const list = (await read($, dispatches)) ?? []
442    const at = (await read($, now)) ?? 0
443    const shownTab = (await read($, tab)) ?? 'active'
444    const width = Math.max(10, e.props.bodyColumns)
445    // The card's inside: the panel's width less its side padding, the card's border and its padding.
446    const stripColumns = Math.max(4, width - 2 * OUTER_X - 2 - 2 * INNER_X)
447    if (e.props.placement === 'dock') measuredColumns = e.props.bodyColumns
448    // One card per top-level row; what a dispatched agent started is listed inside its card,
449    // and the whole tree stays in Active while any row in it runs.
450    const tree = buildTree(list)
451    const live = liveRootIds(list, isRunning)
452    const roots = list.filter(row => tree.rootIdOf(row) === row.id)
453    const active = roots.filter(row => live.has(row.id))
454    const finished = roots.filter(row => !live.has(row.id)).reverse()
455    const shown = shownTab === 'active' ? active : finished
456    const showTab = (next: DispatchTab) => () => void update($, tab, () => next)
457
458    // Terminal spacing is in cells, not pixels: a cell is about 10px wide and 20px tall,
459    // so OUTER (about 20px) is 2 columns or 1 row, and INNER (about 10px) is 1 column.
460    // Half a row does not exist, so the gap between rows (about 10px asked) is 0 rows.
461    const elapsedOf = (row: Dispatch) => (row.endedAt ?? Math.max(at, row.startedAt)) - row.startedAt
462    const markColorOf = (row: Dispatch) => (row.status === 'failed' ? FAILED_MARK_COLOR : activityColor(row))
463
464    /** One line inside a card for an agent or shell the card's agent started, indented by depth. */
465    const childLine = ({ row, depth, isLast }: Descendant) => {
466      const running = isRunning(row)
467      const color = activityColor(row)
468      const tag = row.kind === 'shell' ? 'shell' : activityTag(row)
469      return (
470        <Box key={row.id} flexDirection="row" gap={1}>
471          {/* Branch, mark and title in one text, so their spacing never collapses as the title truncates. */}
472          <Box flexShrink={1}>
473            <Text wrap="truncate-end">
474              <Text dimColor>{`${'  '.repeat(depth)}${isLast ? '└' : '├'} `}</Text>
475              <Text color={markColorOf(row)} dimColor={!running}>
476                {statusMark(row.status)}
477              </Text>
478              <Text color={color} dimColor={!running}>{` ${row.purpose || row.label}`}</Text>
479            </Text>
480          </Box>
481          {tag !== undefined && (
482            <Box flexShrink={0}>
483              <Text color={color} dimColor>{`[${tag}]`}</Text>
484            </Box>
485          )}
486          <Box flexGrow={1} />
487          <Box flexShrink={0}>
488            <Text dimColor={!running}>{formatDuration(elapsedOf(row))}</Text>
489          </Box>
490        </Box>
491      )
492    }
493
494    const card = (row: Dispatch) => {
495      const running = isRunning(row)
496      const isLive = live.has(row.id)
497      const elapsed = elapsedOf(row)
498      const color = activityColor(row)
499      const tag = activityTag(row)
500      const markColor = markColorOf(row)
501      const descendants = descendantsOf(tree, row.id)
502      const subInput = descendants.reduce((sum, { row: child }) => sum + (child.inputTokens ?? 0), 0)
503      const subOutput = descendants.reduce((sum, { row: child }) => sum + (child.outputTokens ?? 0), 0)
504      return (
505        <Box
506          key={row.id}
507          flexDirection="column"
508          borderStyle="round"
509          borderColor={color}
510          borderDimColor={!isLive}
511          paddingX={INNER_X}
512          marginBottom={ROW_GAP}
513        >
514          <Box flexDirection="row" gap={1}>
515            <Text color={markColor} dimColor={!running}>
516              {statusMark(row.status)}
517            </Text>
518            <Box flexShrink={1}>
519              <Text color={color} bold={running} dimColor={!running} wrap="truncate-end">
520                {row.purpose || row.label}
521              </Text>
522            </Box>
523            {tag !== undefined && (
524              <Box flexShrink={0}>
525                <Text color={color} dimColor={!running}>{`[${tag}]`}</Text>
526              </Box>
527            )}
528            <Box flexGrow={1} />
529            <Box flexShrink={0}>
530              <Text dimColor={!running}>{formatDuration(elapsed)}</Text>
531            </Box>
532          </Box>
533          {row.kind === 'agent' && !GENERIC_TYPES.has(row.label) && (
534            <Text dimColor italic wrap="truncate-end">
535              {row.label}
536            </Text>
537          )}
538          {row.kind === 'agent' && (
539            <Text dimColor wrap="truncate-end">
540              {`in ${formatCount(row.inputTokens ?? 0)} · out ${formatCount(row.outputTokens ?? 0)}`}
541              {subInput + subOutput > 0 ? ` · sub in ${formatCount(subInput)} · out ${formatCount(subOutput)}` : ''}
542            </Text>
543      )}
544          {descendants.map(childLine)}
545          {row.kind === 'agent' && running && row.action !== undefined && (
546            // A row cannot be split, so the strip's top and bottom edges are half blocks:
547            // they read as half a row of space above and below a slightly taller strip.
548            <Box flexDirection="column" width={stripColumns}>
549              <Text color={ACTION_BACKGROUND}>{'▄'.repeat(stripColumns)}</Text>
550              <Box backgroundColor={ACTION_BACKGROUND} paddingX={INNER_X}>
551                <Text wrap="truncate-end">
552                  <Text color={color}>▸ </Text>
553                  {row.action}
554                </Text>
555              </Box>
556              <Text color={ACTION_BACKGROUND}>{'▀'.repeat(stripColumns)}</Text>
557            </Box>
558      )}
559        </Box>
560      )
561    }
562
563    const folded = (await read($, collapsed)) ?? { agent: false, shell: false }
564    const sections = (
565      [
566        { kind: 'agent', title: 'Agents', rows: shown.filter(row => row.kind === 'agent') },
567        { kind: 'shell', title: 'Shells', rows: shown.filter(row => row.kind === 'shell') },
568      ] as const
569    ).filter(section => section.rows.length > 0)
570    const toggle = (kind: DispatchKind) => () =>
571      void update($, collapsed, state => {
572        const current = state ?? { agent: false, shell: false }
573        return { ...current, [kind]: !current[kind] }
574      })
575
576    // The footer: one bar per rate-limit window of the account, pinned to the pane's bottom
577    // while the rows fit, and following the rows when they scroll.
578    const windows = (await read($, limits)) ?? []
579    const footerColumns = Math.max(4, width - 2 * OUTER_X)
580    const limitBar = (limit: UsageLimit) => {
581      const percent = Math.max(0, Math.min(100, limit.percentUsed))
582      const filled = Math.round((percent / 100) * footerColumns)
583      const resets = formatUntil(limit.resetsAt, at)
584      return (
585        <Box key={limit.kind} flexDirection="column">
586          <Box flexDirection="row" gap={1}>
587            <Box flexShrink={1}>
588              <Text dimColor wrap="truncate-end">
589                {LIMIT_NAMES[limit.kind] ?? limit.kind.replace(/_/g, ' ')}
590              </Text>
591            </Box>
592            <Box flexGrow={1} />
593            <Text color={limitColor(percent)}>{`${Math.round(limit.percentUsed)}%`}</Text>
594            {resets !== '' && <Text dimColor>{`· resets in ${resets}`}</Text>}
595          </Box>
596          <Text>
597            <Text color={limitColor(percent)}>{'█'.repeat(filled)}</Text>
598            <Text color={LIMIT_EMPTY_COLOR}>{'█'.repeat(footerColumns - filled)}</Text>
599          </Text>
600        </Box>
601      )
602    }
603
604    return (
605      <Box
606        flexDirection="column"
607        width={width}
608        minHeight={e.props.scroll.bodyRows}
609        paddingX={OUTER_X}
610        paddingY={OUTER_Y}
611      >
612        <Box flexDirection="row" gap={OUTER_X} marginBottom={OUTER_Y}>
613          <Button
614            key="tab-active"
615            hotkey="a"
616            variant={shownTab === 'active' ? 'primary' : 'secondary'}
617            onPress={showTab('active')}
618          >
619            Active ({active.length})
620          </Button>
621          <Button
622            key="tab-finished"
623            hotkey="f"
624            variant={shownTab === 'finished' ? 'primary' : 'secondary'}
625            onPress={showTab('finished')}
626          >
627            Finished ({finished.length})
628          </Button>
629          {shownTab === 'finished' && finished.length > 0 && (
630            <Button key="clear" plain dimColor onPress={() => void update($, dispatches, rows => dropFinishedTrees(rows ?? [], isRunning))}>
631              clear
632            </Button>
633          )}
634        </Box>
635        {shown.length === 0 && (
636          <Text dimColor>{shownTab === 'active' ? 'Nothing running.' : 'Nothing finished yet.'}</Text>
637        )}
638        {sections.map(section => (
639          <Box key={section.title} flexDirection="column" marginBottom={OUTER_Y}>
640            <Button key={`section-${section.kind}`} plain onPress={toggle(section.kind)}>
641              <Text dimColor bold>
642                {folded[section.kind] ? '▸' : '▾'} {section.title} ({section.rows.length})
643              </Text>
644            </Button>
645            {!folded[section.kind] && section.rows.map(card)}
646          </Box>
647        ))}
648        {windows.length > 0 && <Box flexGrow={1} />}
649        {windows.length > 0 && (
650          <Box flexDirection="column">
651            <Text dimColor>{'─'.repeat(footerColumns)}</Text>
652            {windows.map(limitBar)}
653          </Box>
654        )}
655      </Box>
656    )
657  })
658}
659
hooks/actions.ts 124 lines
1/**
2 * Turns one tool call into a short phrase of what the agent is doing right now
3 * ("reading register.tsx", "committing", "running tests"). Pure and synchronous:
4 * it reads only the call's own input, so it costs nothing per call.
5 */
6
7const MAX_DETAIL = 28
8
9const clip = (text: string, max = MAX_DETAIL) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
10
11const baseName = (path: unknown) => (typeof path === 'string' ? (path.split('/').pop() ?? path) : '')
12
13const field = (input: Record<string, unknown>, name: string) => {
14  const value = input[name]
15  return typeof value === 'string' ? value : ''
16}
17
18/** Recognised shell commands, checked in order; the first match names the action. */
19const SHELL_ACTIONS: ReadonlyArray<readonly [RegExp, string]> = [
20  [/\bgit\s+commit\b/, 'committing'],
21  [/\bgit\s+push\b/, 'pushing'],
22  [/\bgit\s+(pull|fetch)\b/, 'pulling'],
23  [/\bgit\s+rebase\b/, 'rebasing'],
24  [/\bgit\s+merge\b/, 'merging'],
25  [/\bgit\s+(checkout|switch|branch|worktree)\b/, 'switching branches'],
26  [/\bgit\s+(add|stash|reset|restore)\b/, 'staging changes'],
27  [/\bgit\s+(diff|status|log|show|blame)\b/, 'checking changes'],
28  [/\bgh\s+pr\s+create\b/, 'opening a PR'],
29  [/\bgh\s+pr\s+(comment|review)\b/, 'commenting on a PR'],
30  [/\bgh\s+pr\s+(merge|ready)\b/, 'merging a PR'],
31  [/\bgh\s+(pr|run)\s+(checks|watch|view)\b/, 'checking CI'],
32  [/\bgh\s+pr\b/, 'checking a PR'],
33  [/\b(vitest|jest|playwright|pytest)\b|\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b/, 'running tests'],
34  [/\btsc\b|\btypecheck\b/, 'type-checking'],
35  [/\b(eslint|oxlint|biome|lint)\b/, 'linting'],
36  [/\b(prettier|oxfmt)\b|\bformat\b/, 'formatting'],
37  [/\b(npm|pnpm|yarn|bun)\s+(install|i|add)\b/, 'installing dependencies'],
38  [/\bbuild\b/, 'building'],
39  [/\b(curl|wget)\b/, 'fetching a URL'],
40  [/\b(rg|grep|find|fd)\b/, 'searching files'],
41  [/\b(cat|head|tail|less|sed\s+-n)\b/, 'reading files'],
42]
43
44const shellAction = (input: Record<string, unknown>) => {
45  const command = field(input, 'command')
46  for (const [pattern, action] of SHELL_ACTIONS) if (pattern.test(command)) return action
47  const description = field(input, 'description')
48  if (description) return clip(description.charAt(0).toLowerCase() + description.slice(1), 36)
49  const program = command.trim().split(/\s+/)[0] ?? ''
50  return program ? `running ${clip(baseName(program))}` : 'running a command'
51}
52
53/** What an MCP tool call does, from its server and tool names. */
54const mcpAction = (tool: string) => {
55  const [, server = '', name = ''] = tool.split('__')
56  const isWrite = /create|update|edit|add|transition|comment|post|send|write|delete|move/i.test(name)
57  const service = /jira|atlassian/i.test(server + name)
58    ? 'Jira'
59    : /slack/i.test(server)
60      ? 'Slack'
61      : /notion/i.test(server)
62        ? 'Notion'
63        : /figma/i.test(server)
64          ? 'Figma'
65          : /drive|docs|sheets|gmail|calendar/i.test(server)
66            ? 'Google'
67            : server.replace(/^claude_ai_/, '').replace(/_/g, ' ')
68  return isWrite ? `updating ${service}` : `reading ${service}`
69}
70
71export const describeAction = (tool: string, input: Record<string, unknown>): string => {
72  if (tool.startsWith('mcp__')) return mcpAction(tool)
73  switch (tool) {
74    case 'Bash':
75      return shellAction(input)
76    case 'Read':
77      return `reading ${clip(baseName(input.file_path))}`
78    case 'Edit':
79    case 'MultiEdit':
80      return `editing ${clip(baseName(input.file_path))}`
81    case 'Write':
82      return `writing ${clip(baseName(input.file_path))}`
83    case 'NotebookEdit':
84      return `editing ${clip(baseName(input.notebook_path))}`
85    case 'Grep':
86      return `searching for "${clip(field(input, 'pattern'), 20)}"`
87    case 'Glob':
88      return 'finding files'
89    case 'WebFetch': {
90      const url = field(input, 'url')
91      const host = /^https?:\/\/([^/]+)/.exec(url)?.[1] ?? 'a page'
92      return `reading ${clip(host)}`
93    }
94    case 'WebSearch':
95      return 'searching the web'
96    case 'Agent':
97      return 'dispatching an agent'
98    case 'Skill':
99      return `using skill ${clip(field(input, 'skill'))}`
100    case 'TodoWrite':
101    case 'TaskCreate':
102    case 'TaskUpdate':
103      return 'updating its tasks'
104    case 'ToolSearch':
105      return 'loading tools'
106    default:
107      return `using ${clip(tool)}`
108  }
109}
110
111/** Shown between tool calls, while the agent's model is writing its next step. */
112export const THINKING = 'thinking'
113
114/**
115 * A background shell's title when its call gave no description: the action its command
116 * is recognised as ("checking a PR", "running tests"), else the command's first line.
117 * A raw loop such as `until [ "$(gh pr view 1233 ...` reads as noise in a card.
118 */
119export const shellTitle = (command: string) => {
120  const action = shellAction({ command })
121  if (!action.startsWith('running ') || action === 'running tests') return action.charAt(0).toUpperCase() + action.slice(1)
122  return command.split('\n')[0]?.trim() ?? ''
123}
124
hooks/kinds.ts 59 lines
1import type { Dispatch } from '../types'
2
3/**
4 * What an agent is doing, each with its own colour. The colour follows the activity,
5 * not the agent type: a `general-purpose` agent writing code and one updating Jira get
6 * different colours. A small model picks the activity when the agent starts. The tag is
7 * the short name shown in brackets after the agent's type.
8 */
9export const ACTIVITIES = [
10  { label: 'writing or changing code', tag: 'code', color: '#3b82f6' },
11  { label: 'reviewing or testing', tag: 'review', color: '#ef4444' },
12  { label: 'updating tickets (Jira, issues)', tag: 'jira', color: '#22d3ee' },
13  { label: 'git, branches or pull requests', tag: 'git', color: '#ec4899' },
14  { label: 'searching or researching', tag: 'research', color: '#facc15' },
15  { label: 'planning or designing', tag: 'plan', color: '#22c55e' },
16  { label: 'writing docs or text', tag: 'docs', color: '#e5e5e5' },
17  { label: 'configuring or setting up', tag: 'setup', color: '#a855f7' },
18  { label: 'analysing data', tag: 'data', color: '#a3e635' },
19  { label: 'something else', tag: 'other', color: '#f97316' },
20] as const
21
22export const ACTIVITY_LABELS = ACTIVITIES.map(activity => activity.label)
23
24/** Shells, and agents whose activity is not known yet. */
25export const NEUTRAL_COLOR = '#94a3b8'
26
27/** The colour of a failed row's ✗ mark; its border keeps its activity's colour. */
28export const FAILED_MARK_COLOR = '#ef4444'
29
30export const activityColor = (row: Dispatch) =>
31  ACTIVITIES.find(activity => activity.label === row.activity)?.color ?? NEUTRAL_COLOR
32
33/** The short activity tag shown after the agent's type, once the classifier has picked one. */
34export const activityTag = (row: Dispatch) => ACTIVITIES.find(activity => activity.label === row.activity)?.tag
35
36/**
37 * Words in an agent's description that name its activity outright. They settle it the
38 * moment the agent starts, before any model call, and the classifier cannot overrule
39 * them. Only unambiguous signals belong here: a ticket key alone does not, since dev
40 * agents carry one too ("Build PROD-4888").
41 */
42const CLEAR_SIGNALS: ReadonlyArray<readonly [RegExp, (typeof ACTIVITIES)[number]['label']]> = [
43  [/\b(jira|tickets?|backlog|sprint)\b/i, 'updating tickets (Jira, issues)'],
44  [/\b(commit|push|rebase|cherry-pick)\b|\bpull requests?\b|\b(open|create|merge|arm)\b.*\bPRs?\b/i, 'git, branches or pull requests'],
45]
46
47/** The activity a description names outright, or undefined when the classifier has to decide. */
48export const activityFromDescription = (description: string) =>
49  CLEAR_SIGNALS.find(([pattern]) => pattern.test(description))?.[1]
50
51/**
52 * Notes for the classifier on the confusions it made in use: writing a ticket's text is
53 * still updating tickets, not writing docs.
54 */
55export const CLASSIFIER_HINTS = [
56  'Creating, editing, commenting on or moving Jira issues or tickets is "updating tickets (Jira, issues)", even when the work is writing text.',
57  '"writing docs or text" is documentation, READMEs, notes and pages, not tickets.',
58].join('\n')
59
hooks/tree.ts 60 lines
1import type { Dispatch } from '../types'
2
3/**
4 * Groups rows by who started them. An agent or shell that a dispatched agent started
5 * has that agent as `parentId`; the panel draws one card per top-level row and lists
6 * the rest inside it. A row whose parent is not tracked (cleared, or never seen) is
7 * top-level itself.
8 */
9export type Tree = {
10  /** Each row's top-level ancestor's id. */
11  rootIdOf: (row: Dispatch) => string
12  /** The rows a row started, in the order they were tracked. */
13  childrenOf: (id: string) => Dispatch[]
14}
15
16export const buildTree = (rows: readonly Dispatch[]): Tree => {
17  const byId = new Map(rows.map(row => [row.id, row]))
18  const children = new Map<string, Dispatch[]>()
19  for (const row of rows) {
20    if (row.parentId === undefined || !byId.has(row.parentId)) continue
21    children.set(row.parentId, [...(children.get(row.parentId) ?? []), row])
22  }
23  const rootIdOf = (row: Dispatch) => {
24    let current = row
25    const seen = new Set<string>()
26    while (current.parentId !== undefined && !seen.has(current.id)) {
27      seen.add(current.id)
28      const parent = byId.get(current.parentId)
29      if (parent === undefined) break
30      current = parent
31    }
32    return current.id
33  }
34  return { rootIdOf, childrenOf: id => children.get(id) ?? [] }
35}
36
37/** The ids of the top-level rows whose tree still has a row running. */
38export const liveRootIds = (rows: readonly Dispatch[], isRunning: (row: Dispatch) => boolean) => {
39  const tree = buildTree(rows)
40  return new Set(rows.filter(isRunning).map(tree.rootIdOf))
41}
42
43/** Drops the rows of every tree with nothing left running; a finished child of a live tree stays. */
44export const dropFinishedTrees = (rows: readonly Dispatch[], isRunning: (row: Dispatch) => boolean) => {
45  const tree = buildTree(rows)
46  const live = liveRootIds(rows, isRunning)
47  return rows.filter(row => live.has(tree.rootIdOf(row)))
48}
49
50export type Descendant = { row: Dispatch; depth: number; isLast: boolean }
51
52/** A row's descendants, depth first, each with its depth below the row and whether it is its parent's last child. */
53export const descendantsOf = (tree: Tree, id: string, depth = 0): Descendant[] => {
54  const children = tree.childrenOf(id)
55  return children.flatMap((row, index) => [
56    { row, depth, isLast: index === children.length - 1 },
57    ...descendantsOf(tree, row.id, depth + 1),
58  ])
59}
60
types/index.d.ts 55 lines
1export type DispatchKind = 'agent' | 'shell'
2
3export type DispatchTab = 'active' | 'finished'
4
5export type Dispatch = {
6  id: string
7  kind: DispatchKind
8  /** Agent type (`Explore`, `general-purpose`) or `bash`. */
9  label: string
10  /** The Agent call's description, or the Bash call's description or command. */
11  purpose: string
12  status: string
13  startedAt: number
14  endedAt?: number
15  /** Input tokens the agent has sent across all its requests, cache reads and writes included; absent for shells. */
16  inputTokens?: number
17  /** Output tokens the agent has generated across all its requests; absent for shells. */
18  outputTokens?: number
19  /** The start of the agent's prompt, kept so its activity can be classified. */
20  brief?: string
21  /** What the agent is doing, one of the activity labels; absent until classified. */
22  activity?: string
23  /** True once classification was tried, so a failed attempt is not repeated. */
24  isClassified?: boolean
25  /** What a running agent is doing right now ("reading register.tsx", "committing"). */
26  action?: string
27  /** The agent that started this agent or shell; absent when the main thread did. */
28  parentId?: string
29}
30
31/** One of the account's rate-limit windows, as the last API response reported it. */
32export type UsageLimit = {
33  /** `five_hour`, `seven_day`, or a gateway's `spend_limit`. */
34  kind: string
35  /** How much of the window is used, 0 to 100. */
36  percentUsed: number
37  /** When the window resets, as an ISO 8601 timestamp. */
38  resetsAt?: string
39}
40
41declare module 'claude-code' {
42  interface PluginState {
43    'dispatch-panel': {
44      dispatches: Dispatch[]
45      now: number
46      tab: DispatchTab
47      isSized: boolean
48      /** Which sections the person collapsed, by kind. */
49      collapsed: Record<DispatchKind, boolean>
50      /** The account's rate-limit windows, drawn as bars in the footer; empty off a subscription. */
51      limits: UsageLimit[]
52    }
53  }
54}
55