SLOPSHOPPER

delegation-board

A live pane of the session's subagents and teammates: type, model, effort, state, elapsed time, tokens, an optional cost estimate and the task description. It…

newpanebandguardcommandtimer
★ 1v0.1.0MITupdated 2026-10-13iuliandita/mods/mods/delegation-board/claude
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · delegation-board
│ ┃ Delegation ✕ › fix the failing auth test and add an audit log call │ ┃ No subagents yet. │ ⏺ 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 │ │ › /board │ ⎿ delegation-board: delegation-board: pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Delegation
No subagents yet.
README

mods

Claude Code mods you can install one at a time. guard-rules, secret-box, prompt-gate and skill-pick also run on Codex and OpenCode, and statusline and delegation-board on OpenCode; what works on which host, and why.

Mods are plugins built on the hooks-module API. They run with your permissions and are not sandboxed: read a mod before installing it. Each mod lists what leaves your machine (egress) in its README and in mods.json.

Install

claude plugin marketplace add iuliandita/mods claude plugin install <mod>@iuliandita-mods

Run it in a terminal. It works for Claude Code and loads in Claude Desktop sessions too.

These calls install from the GitHub marketplace. From a clone, scripts/mods install <mod> registers that clone as the marketplace instead (or --source <owner/repo>).

For Codex and OpenCode, see the mod's README and INSTALL.md. OpenCode hook plugins use opencode.json; its terminal UI plugins use the global cli.json instead. Prompt advisers make model calls even in their default dry mode; check the host's costs and privacy limits before installing them.

Catalog

  • guard-rules: blocks AI attribution in commits and PRs, direct commits to protected branches, commits under the wrong git identity, and mutating kubectl, helm and terraform calls on protected or unclassified environments.
  • secret-box: keeps secret values out of the chat; a secret placeholder in a Bash command is filled in at execution time from the OS keyring.
  • prompt-gate: before the main model spends a turn on a vague prompt, a small model says so and asks one short question. With a TypeSafe API key in the environment the prompt goes to a third party (Jev); jevPaths restricts where.
  • skill-pick: names the 1 to 5 installed skills that fit each prompt. With a TypeSafe API key the prompt and skill descriptions go to a third party (Jev); jevPaths restricts where.
  • delegation-board: a live pane of the session's subagents and teammates: type, model, effort, state, elapsed time and tokens; in Claude Desktop, a compact band above the prompt.
  • statusline: a two-row status line above the prompt in the terminal and Claude Desktop: model and effort, git state, context against your auto-compact window, 5h and 7d limits with pace, cache, cost and time. In a git repo with a GitHub or GitLab remote it runs gh or glab for PR and issue counts; forge=off turns that off.

mods.json has the full catalog: egress, modes, requirements, and the install command per mod. All six mods have recorded Claude terminal checks; the compatibility page lists the tested behavior and versions. Other Claude surfaces remain unverified.

Private mods

Put your own mods in local/mods/<name>/ (gitignored) and install them with scripts/mods install <name> --local.

License

MIT

Project and support

Made by iuliandita. Other projects.

See installation and recovery, contributing, documentation, and private security reporting.

agents provides global prompts and role definitions; skills provides individually installed task guidance. Installing one collection does not install the others.

Source 3 files
hooks/register.tsx 264 lines
1import { atom, read } from 'claude-code'
2import type { AgentInfo, EngineInterface as Engine, Register, Timer } from 'claude-code'
3
4import type { Config, Effort, Row } from './board'
5import { MAX_ROWS, addSpawn, compact, addTurn, claimEffort, clearFinished, EMPTY, fit, footer, header, joinRows, lines, mergeStatus, needsPoll, newRow, noteEffort, parseConfig, sanitizeRows, statusText, USAGE } from './board'
6
7const PANE = 'delegation-board'
8const TITLE = 'Delegation'
9const LIST_MS = 1000
10const STALE_MS = 3000
11
12const AGENTS = { plugin: 'delegation-board', key: 'agents' } as const
13const TICK = { plugin: 'delegation-board', key: 'tick' } as const
14const OPENED = { plugin: 'delegation-board', key: 'opened' } as const
15const agents = atom(AGENTS, [] as Row[])
16const tick = atom(TICK, 0)
17
18async function nowMs($: Engine): Promise<number> {
19  try {
20    return await $.clock.now()
21  } catch {
22    return Date.now()
23  }
24}
25
26// The rows live here and every hook changes them in one synchronous step; state only mirrors them for the
27// pane. A timer or a hook never reads state back: one dispatch reads one moment, and parallel hooks would lose updates.
28type Ctx = {
29  cfg: Config
30  efforts: Map<string, Effort>
31  rows: Row[]
32  tick: number
33  opened: boolean
34  timer: Timer | null
35  lastTick: number
36  isPolling: boolean
37}
38
39async function publish($: Engine, s: Ctx): Promise<void> {
40  await $.state.set(AGENTS, s.rows)
41  await $.state.set(TICK, s.tick)
42  // The pane redraws from state; the band above the prompt draws from memory, so it is asked to.
43  try {
44    $.ui.invalidate('ui.render')
45  } catch {
46    // fail open: the band redraws on the next change
47  }
48}
49
50async function markOpened($: Engine, s: Ctx): Promise<void> {
51  s.opened = true
52  await $.state.set(OPENED, true)
53}
54
55// A failed or hung call is no information: null leaves every status as it was.
56async function listAgents($: Engine): Promise<AgentInfo[] | null> {
57  const stop = new AbortController()
58  try {
59    const wait = $.clock.sleep(LIST_MS, { signal: stop.signal }).then(() => null, () => null)
60    return await Promise.race([$.agent.list(), wait])
61  } catch {
62    return null
63  } finally {
64    stop.abort()
65  }
66}
67
68function halt(s: Ctx): void {
69  s.timer?.cancel()
70  s.timer = null
71}
72
73async function poll($: Engine, s: Ctx): Promise<void> {
74  if (s.isPolling) return
75  s.isPolling = true
76  try {
77    const listed = await listAgents($)
78    const now = await nowMs($)
79    if (listed) s.rows = mergeStatus(s.rows, listed, now)
80    s.tick++
81    s.lastTick = now
82    // Decided in the same step as the merge, so a spawn that lands while the write is in flight finds no timer and starts one.
83    if (!needsPoll(s.rows)) halt(s)
84    await publish($, s)
85  } finally {
86    s.isPolling = false
87  }
88}
89
90// One timer while a row needs polling; one that stopped ticking is replaced.
91async function start($: Engine, s: Ctx): Promise<void> {
92  const now = await nowMs($)
93  if (s.timer && (now - s.lastTick <= STALE_MS || !needsPoll(s.rows))) return
94  s.timer?.cancel()
95  s.lastTick = now
96  s.timer = $.clock.every(1000, () => void poll($, s).catch(() => {}))
97}
98
99// A /clear (or a resume) starts the board from empty; session.start does not fire for it, session.end does.
100async function reset($: Engine, s: Ctx): Promise<void> {
101  halt(s)
102  s.rows = []
103  s.efforts.clear()
104  s.opened = false
105  await publish($, s)
106  await $.state.set(OPENED, false)
107}
108
109async function hydrate($: Engine, s: Ctx): Promise<void> {
110  const loaded = sanitizeRows((await $.state.get(AGENTS)).value)
111  const have = new Set(s.rows.map((r) => r.id))
112  s.rows = joinRows(loaded.filter((r) => !have.has(r.id)), s.rows)
113  s.opened = s.opened || (await $.state.get(OPENED)).value === true
114  if (needsPoll(s.rows)) await start($, s)
115}
116
117export const register: Register = (on, options) => {
118  const cfg = parseConfig(options)
119  const s: Ctx = { cfg, efforts: new Map(), rows: [], tick: 0, opened: false, timer: null, lastTick: 0, isPolling: false }
120
121  on('session.start', async ($, e, next) => {
122    await $.command.register({
123      name: 'board',
124      description: 'Toggle the delegation pane, clear its finished rows, or print its summary.',
125      argumentHint: '[clear|status]',
126    })
127    // A reload starts a fresh module: take the rows back from state and resume polling.
128    if (cfg.mode !== 'off') {
129      try {
130        await hydrate($, s)
131      } catch {
132        // fail open: the board starts empty
133      }
134    }
135    return next(e)
136  }).catch(($, e, next) => next(e))
137
138  on('session.end', async ($, e, next) => {
139    if (e.reason === 'clear' || e.reason === 'resume') {
140      try {
141        await reset($, s)
142      } catch {
143        // fail open: the rows were dropped from memory
144      }
145    }
146    return next(e)
147  }).catch(($, e, next) => next(e))
148
149  on('command.run', { command: 'board' }, async ($, e) => {
150    if (cfg.mode === 'off') return { text: 'delegation-board: mode is off.' }
151    const arg = e.args.trim()
152    if (arg === 'status') return { text: statusText(s.rows, cfg.prices) }
153    if (arg === 'clear') {
154      s.rows = clearFinished(s.rows)
155      await publish($, s)
156      return { text: 'delegation-board: finished rows cleared; the totals now cover the rows left.' }
157    }
158    if (arg !== '') return { text: USAGE }
159    const pane = (await $.ui.panes()).find((p) => p.id === PANE)
160    if (pane && pane.isPlaced && pane.isShown) {
161      await $.ui.close({ id: PANE })
162      return { text: 'delegation-board: pane closed.' }
163    }
164    const opened = await $.ui.open({ id: PANE, title: TITLE })
165    await markOpened($, s)
166    if (opened.isPlaced) return { text: 'delegation-board: pane opened.' }
167    const table = s.rows.length ? [header(s.rows), ...lines(s.rows, cfg.prices, await nowMs($), MAX_ROWS).map((l) => l.text), footer(s.rows, cfg.prices)].join('\n') : EMPTY
168    return { text: `delegation-board: the pane was not placed on this surface (${opened.reason}). On Desktop, compact rows of the running subagents show above the prompt. The table as text:\n${table}` }
169  }).catch(() => ({ text: 'delegation-board: the command failed. Check the debug log.' }))
170
171  if (cfg.mode === 'off') return
172
173  // The hooks below only observe: the engine's value goes on untouched and a recording failure is swallowed.
174  on('agent.spawn', async ($, e, next) => {
175    const result = await next(e)
176    try {
177      if (result.agentId) {
178        const now = await nowMs($)
179        const row = newRow({
180          id: result.agentId,
181          type: e.subagentType,
182          model: result.model,
183          description: e.description,
184          parentId: e.parentAgentId,
185          background: e.background,
186          isTeammate: e.isTeammate,
187          effort: claimEffort(s.efforts, e.tool_use_id, now),
188          now,
189        })
190        s.rows = addSpawn(s.rows, row)
191        const first = cfg.open === 'spawn' && !s.opened
192        if (first) s.opened = true
193        await publish($, s)
194        await start($, s)
195        if (first) {
196          void $.ui.open({ id: PANE, title: TITLE }).catch(() => {})
197          void markOpened($, s).catch(() => {})
198        }
199      }
200    } catch {
201      // fail open: the spawn already happened
202    }
203    return result
204  }).catch(($, e, next) => next(e))
205
206  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
207    try {
208      noteEffort(s.efforts, e.tool_use_id, (e as { effort?: unknown }).effort, await nowMs($))
209    } catch {
210      // fail open
211    }
212    return next(e)
213  }).catch(($, e, next) => next(e))
214
215  on('turn.complete', async ($, e, next) => {
216    const result = await next(e)
217    try {
218      if (e.agentId) {
219        s.rows = addTurn(s.rows, e.agentId, e.usage, await nowMs($))
220        await publish($, s)
221        await start($, s)
222      }
223    } catch {
224      // fail open
225    }
226    return result
227  }).catch(($, e, next) => next(e))
228
229  // Desktop does not place the pane, so it gets the band above the prompt; the terminal keeps the pane.
230  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
231    if (e.surface === 'terminal' || e.props.hasSurvey) return next(e)
232    const band = compact(s.rows, await nowMs($), e.props.bodyColumns)
233    if (band.rows.length === 0) return next(e)
234    const below = await next(e)
235    const { Box, Text } = $.ui.resolve(e)
236    return (
237      <Box flexDirection="column">
238        {below}
239        {band.rows.map((text) => (
240          <Text wrap="truncate-end">{text}</Text>
241        ))}
242        {band.more > 0 ? <Text dimColor>{`+${band.more} more`}</Text> : null}
243      </Box>
244    )
245  }).catch(($, e, next) => next(e))
246
247  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
248    const { Box, Text } = $.ui.resolve(e)
249    const rows = await read($, agents)
250    await read($, tick)
251    const now = await nowMs($)
252    const room = fit((e.props as { scroll?: { bodyRows?: unknown } }).scroll?.bodyRows, e.viewport?.rows)
253    return (
254      <Box flexDirection="column">
255        {rows.length === 0 ? <Text dimColor>{EMPTY}</Text> : <Text>{header(rows)}</Text>}
256        {lines(rows, cfg.prices, now, room).map((l) => (
257          <Text dimColor={l.isDone}>{l.text}</Text>
258        ))}
259        {rows.length === 0 ? null : <Text>{footer(rows, cfg.prices)}</Text>}
260      </Box>
261    )
262  }).catch(($, e, next) => next(e))
263}
264
hooks/board.ts 330 lines
1import type { DelegationBoardRow as Row, DelegationBoardState as RowState } from '../types/index'
2
3export type { Row, RowState }
4
5export const MAX_ROWS = 40
6export const DESC_MAX = 60
7export const EFFORT_TTL_MS = 10 * 60 * 1000
8export const MAX_MISSES = 3
9export const USAGE = 'delegation-board: /board toggles the pane, /board clear drops finished rows, /board status prints the summary as text.'
10
11const STATES: readonly string[] = ['pending', 'running', 'waiting', 'idle', 'completed', 'failed', 'killed', 'unknown']
12const FINISHED: readonly string[] = ['completed', 'failed', 'killed']
13const isControl = (c: number): boolean => c < 0x20 || (c >= 0x7f && c <= 0x9f) || (c >= 0x200b && c <= 0x200f) || c === 0x2028 || c === 0x2029 || (c >= 0x202a && c <= 0x202e) || (c >= 0x2066 && c <= 0x2069)
14
15export type Config = { mode: 'off' | 'on'; open: 'spawn' | 'never'; prices: Price[] }
16export type Price = { key: string; input: number; output: number }
17export type Listed = { id: string; status: string }
18export type Usage = { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; model?: string }
19export type Effort = { effort: string; at: number }
20export type Spawned = { id: string; type: string; model: string; description: string; parentId?: string; background?: boolean; isTeammate?: boolean; effort: string | null; now: number }
21
22// Effort rides the Agent tool call; the spawn that follows claims it by tool_use_id. Unclaimed entries expire.
23export function noteEffort(map: Map<string, Effort>, id: unknown, effort: unknown, now: number): void {
24  for (const [key, one] of map) if (now - one.at > EFFORT_TTL_MS) map.delete(key)
25  if (typeof id === 'string' && id && typeof effort === 'string' && effort) map.set(id, { effort, at: now })
26}
27
28export function claimEffort(map: Map<string, Effort>, id: unknown, now: number): string | null {
29  const one = typeof id === 'string' ? map.get(id) : undefined
30  if (typeof id === 'string') map.delete(id)
31  return one && now - one.at <= EFFORT_TTL_MS ? one.effort : null
32}
33
34export const isFinished = (state: string): boolean => FINISHED.includes(state)
35
36// Unknown rows are never named by a spawn so they do not keep the poll running. An idle teammate does: the list is the
37// only place a resumed turn shows, so the poll must still be running when it wakes.
38export const needsPoll = (rows: Row[]): boolean => rows.some((r) => !isFinished(r.state) && r.state !== 'unknown')
39
40// Finished, or given up on (unknown with an end time): drawn dimmed and sorted with the finished.
41const isSettled = (r: Row): boolean => isFinished(r.state) || r.endedAt !== null
42
43const FORMAT = /\p{Cf}/u
44
45// Control, format (bidi, zero-width) characters never reach the pane; text is cut by code point.
46export function clean(value: unknown, max: number): string {
47  if (typeof value !== 'string') return ''
48  const spaced = Array.from(value, (ch) => (isControl(ch.codePointAt(0) ?? 0) || FORMAT.test(ch) ? ' ' : ch)).join('')
49  const points = Array.from(spaced.replace(/\s+/g, ' ').trim())
50  return points.slice(0, max).join('')
51}
52
53const count = (n: unknown): number => (typeof n === 'number' && Number.isFinite(n) && n > 0 ? n : 0)
54
55function parseMode(v: unknown): Config['mode'] {
56  return v === 'off' ? 'off' : 'on'
57}
58
59// Any malformed entry drops the whole table: a half-read price list would price some rows and not others.
60export function parsePrices(raw: unknown): Price[] {
61  if (typeof raw !== 'string' || !raw.trim()) return []
62  try {
63    const data: unknown = JSON.parse(raw)
64    if (typeof data !== 'object' || data === null || Array.isArray(data)) return []
65    const out: Price[] = []
66    for (const [key, pair] of Object.entries(data)) {
67      if (!key.trim() || !Array.isArray(pair) || pair.length !== 2) return []
68      const [input, output] = pair as unknown[]
69      for (const n of [input, output]) if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return []
70      out.push({ key: key.trim().toLowerCase(), input: input as number, output: output as number })
71    }
72    return out
73  } catch {
74    return []
75  }
76}
77
78export function parseConfig(o: Record<string, unknown>): Config {
79  return { mode: parseMode(o.mode), open: o.open === 'never' ? 'never' : 'spawn', prices: parsePrices(o.prices) }
80}
81
82export function priceFor(model: string, prices: Price[]): Price | null {
83  const m = model.toLowerCase()
84  let best: Price | null = null
85  for (const p of prices) if (m.includes(p.key) && (!best || p.key.length > best.key.length)) best = p
86  return best
87}
88
89export const inTokens = (r: Row): number => r.input + r.cacheRead + r.cacheWrite
90
91export function estimate(r: Row, prices: Price[]): number | null {
92  const p = priceFor(r.model, prices)
93  return p ? (inTokens(r) * p.input + r.output * p.output) / 1e6 : null
94}
95
96export function fmtCost(usd: number): string {
97  return `~$${usd >= 0.1 ? usd.toFixed(2) : usd.toFixed(3)}`
98}
99
100export function fmtTokens(n: number): string {
101  if (n < 1000) return String(Math.round(n))
102  const k = n / 1000
103  if (k < 9.95) return `${k.toFixed(1).replace(/\.0$/, '')}k`
104  if (n < 999500) return `${Math.round(k)}k`
105  return `${(n / 1e6).toFixed(1).replace(/\.0$/, '')}M`
106}
107
108export function fmtElapsed(ms: number): string {
109  const s = Math.max(0, Math.floor(ms / 1000))
110  if (s < 60) return `${s}s`
111  return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
112}
113
114export function newRow(f: Spawned): Row {
115  return {
116    id: f.id,
117    parentId: f.parentId || null,
118    type: clean(f.type, 40) || 'unknown',
119    model: clean(f.model, 60) || 'unknown',
120    effort: f.effort ? clean(f.effort, 10) || null : null,
121    state: 'pending',
122    description: clean(f.description, DESC_MAX),
123    background: f.background === true,
124    isTeammate: f.isTeammate === true,
125    startedAt: f.now,
126    endedAt: null,
127    turns: 0,
128    input: 0,
129    output: 0,
130    cacheRead: 0,
131    cacheWrite: 0,
132    misses: 0,
133  }
134}
135
136// Over the cap, finished and unknown rows go first (oldest first); live rows only when nothing else is left.
137function keep(rows: Row[]): Row[] {
138  let extra = rows.length - MAX_ROWS
139  if (extra <= 0) return rows
140  const drop = new Set<string>()
141  const take = (want: (r: Row) => boolean) => {
142    for (const r of rows) {
143      if (extra <= 0) return
144      if (!drop.has(r.id) && want(r)) {
145        drop.add(r.id)
146        extra--
147      }
148    }
149  }
150  take((r) => isFinished(r.state) || r.state === 'unknown')
151  take(() => true)
152  return rows.filter((r) => !drop.has(r.id))
153}
154
155export const joinRows = (first: Row[], then: Row[]): Row[] => keep([...first, ...then])
156
157// A row already made by an early turn.complete keeps its counts when the spawn arrives late.
158export function addSpawn(rows: Row[], row: Row): Row[] {
159  const old = rows.find((r) => r.id === row.id)
160  if (!old) return keep([...rows, row])
161  const merged: Row = { ...row, state: old.state === 'unknown' ? row.state : old.state, turns: old.turns, input: old.input, output: old.output, cacheRead: old.cacheRead, cacheWrite: old.cacheWrite, misses: old.misses }
162  return keep(rows.map((r) => (r.id === row.id ? merged : r)))
163}
164
165export function addTurn(rows: Row[], agentId: string, usage: Usage | undefined, now: number): Row[] {
166  const add = (r: Row): Row => ({
167    ...r,
168    turns: r.turns + 1,
169    input: r.input + count(usage?.input_tokens),
170    output: r.output + count(usage?.output_tokens),
171    cacheRead: r.cacheRead + count(usage?.cache_read_input_tokens),
172    cacheWrite: r.cacheWrite + count(usage?.cache_creation_input_tokens),
173  })
174  if (rows.some((r) => r.id === agentId)) return rows.map((r) => (r.id === agentId ? add(r) : r))
175  const model = clean(usage?.model, 60) || 'unknown'
176  const blank: Row = { ...newRow({ id: agentId, type: 'unknown', model, description: '', effort: null, now }), state: 'unknown' }
177  return keep([...rows, add(blank)])
178}
179
180// Only for a call that succeeded (an empty list included). The engine's status is authoritative and the end
181// time is the first poll that sees a finished one. A live row the list does not name MAX_MISSES polls
182// running (workflow agents, teammates the engine dropped) is given up on as unknown.
183export function mergeStatus(rows: Row[], listed: Listed[], now: number): Row[] {
184  const by = new Map(listed.map((a) => [a.id, a.status]))
185  return rows.map((r) => {
186    const status = by.get(r.id)
187    if (status === undefined) {
188      if (isFinished(r.state) || r.state === 'unknown') return r
189      const misses = r.misses + 1
190      return misses >= MAX_MISSES ? { ...r, misses, state: 'unknown', endedAt: r.endedAt ?? now } : { ...r, misses }
191    }
192    if (typeof status !== 'string' || !STATES.includes(status) || status === 'unknown') return { ...r, misses: 0 }
193    const state = status as RowState
194    if (isFinished(state)) return { ...r, state, misses: 0, endedAt: r.endedAt ?? now }
195    return { ...r, state, misses: 0, endedAt: null }
196  })
197}
198
199const text = (v: unknown, max: number, fallback: string): string => clean(v, max) || fallback
200
201// Rows read back from state at startup are not trusted to have the shape this code wrote.
202export function sanitizeRows(value: unknown): Row[] {
203  if (!Array.isArray(value)) return []
204  const out: Row[] = []
205  for (const x of value as Record<string, unknown>[]) {
206    if (!x || typeof x !== 'object' || typeof x.id !== 'string' || !x.id) continue
207    const state = typeof x.state === 'string' && STATES.includes(x.state) ? (x.state as RowState) : 'unknown'
208    out.push({
209      id: x.id,
210      parentId: typeof x.parentId === 'string' && x.parentId ? x.parentId : null,
211      type: text(x.type, 40, 'unknown'),
212      model: text(x.model, 60, 'unknown'),
213      effort: typeof x.effort === 'string' && x.effort ? clean(x.effort, 10) || null : null,
214      state,
215      description: clean(x.description, DESC_MAX),
216      background: x.background === true,
217      isTeammate: x.isTeammate === true,
218      startedAt: count(x.startedAt),
219      endedAt: typeof x.endedAt === 'number' && Number.isFinite(x.endedAt) ? x.endedAt : null,
220      turns: count(x.turns),
221      input: count(x.input),
222      output: count(x.output),
223      cacheRead: count(x.cacheRead),
224      cacheWrite: count(x.cacheWrite),
225      misses: count(x.misses),
226    })
227  }
228  return keep(out)
229}
230
231export const clearFinished = (rows: Row[]): Row[] => rows.filter((r) => !isFinished(r.state))
232
233// Live rows first (oldest start first, so rows stay put), then the newest settled; a child sits directly
234// after its parent. Orphans and cycles keep the flat order.
235export function sortRows(rows: Row[]): Row[] {
236  const live = rows.filter((r) => !isSettled(r)).sort((a, b) => a.startedAt - b.startedAt)
237  const done = rows.filter(isSettled).sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
238  const flat = [...live, ...done]
239  const ids = new Set(flat.map((r) => r.id))
240  const kids = new Map<string, Row[]>()
241  const roots: Row[] = []
242  for (const r of flat) {
243    if (r.parentId !== null && r.parentId !== r.id && ids.has(r.parentId)) kids.set(r.parentId, [...(kids.get(r.parentId) ?? []), r])
244    else roots.push(r)
245  }
246  const out: Row[] = []
247  const seen = new Set<string>()
248  const walk = (r: Row) => {
249    if (seen.has(r.id)) return
250    seen.add(r.id)
251    out.push(r)
252    for (const k of kids.get(r.id) ?? []) walk(k)
253  }
254  for (const r of roots) walk(r)
255  for (const r of flat) walk(r)
256  return out
257}
258
259export function header(rows: Row[]): string {
260  const n = (s: string) => rows.filter((r) => r.state === s).length
261  const failed = n('failed') + n('killed')
262  return `${rows.length} agents: ${n('running')} running, ${n('waiting')} waiting, ${n('completed')} done, ${failed} failed`
263}
264
265export function footer(rows: Row[], prices: Price[]): string {
266  const input = rows.reduce((a, r) => a + inTokens(r), 0)
267  const output = rows.reduce((a, r) => a + r.output, 0)
268  const costs = rows.map((r) => estimate(r, prices)).filter((c): c is number => c !== null)
269  const unpriced = rows.some((r) => estimate(r, prices) === null && inTokens(r) + r.output > 0)
270  const cost = costs.length ? `, ${fmtCost(costs.reduce((a, c) => a + c, 0))}${unpriced ? '+' : ''}` : ''
271  return `Tokens ${fmtTokens(input)}/${fmtTokens(output)}${cost}`
272}
273
274export const EMPTY = 'No subagents yet.'
275
276// Rows that fit: the pane's body rows less the header and footer, else the viewport less the frame's rows.
277export function fit(bodyRows: unknown, viewportRows: unknown): number {
278  const body = typeof bodyRows === 'number' && Number.isFinite(bodyRows) && bodyRows > 0 ? bodyRows - 2 : null
279  const view = typeof viewportRows === 'number' && Number.isFinite(viewportRows) ? viewportRows : 24
280  return Math.max(1, body ?? view - 4)
281}
282
283export const statusText = (rows: Row[], prices: Price[]): string => (rows.length ? `${header(rows)}\n${footer(rows, prices)}` : EMPTY)
284
285export const BAND_ROWS = 3
286
287const cut = (value: string, width: number): string => {
288  const points = Array.from(value)
289  return points.length <= width ? value : width > 3 ? `${points.slice(0, width - 3).join('')}...` : points.slice(0, Math.max(0, width)).join('')
290}
291
292// The band above the prompt on a surface with no pane: at most `max` one-line rows of the live agents
293// (type, model, state, elapsed, tokens in/out), each cut to `columns`, and how many live agents are left out.
294// The description is added only when at least 8 columns of it fit.
295export function compact(rows: Row[], now: number, columns: number, max = BAND_ROWS): { rows: string[]; more: number } {
296  const width = Number.isFinite(columns) && columns >= 10 ? Math.floor(columns) : 80
297  const live = sortRows(rows).filter((r) => !isSettled(r))
298  const shown = live.slice(0, Math.max(1, max)).map((r) => {
299    const base = [clean(r.type, 40), clean(r.model, 60), r.state, fmtElapsed(now - r.startedAt), `${fmtTokens(inTokens(r))}/${fmtTokens(r.output)}`].join('  ')
300    const room = width - Array.from(base).length - 2
301    const description = room >= 8 ? clean(r.description, DESC_MAX) : ''
302    return cut(description ? `${base}  ${cut(description, room)}` : base, width)
303  })
304  return { rows: shown, more: live.length - shown.length }
305}
306
307export type Line = { text: string; isDone: boolean }
308
309// One text line per row. The cost column is left out unless some row on the board is priced.
310export function lines(rows: Row[], prices: Price[], now: number, room: number): Line[] {
311  const shown = sortRows(rows).slice(0, Math.max(1, room))
312  const ids = new Set(rows.map((r) => r.id))
313  const priced = rows.some((r) => estimate(r, prices) !== null)
314  return shown.map((r) => {
315    const cost = estimate(r, prices)
316    const cols = [
317      r.state.padEnd(9),
318      clean(r.type, 40),
319      clean(r.model, 60),
320      clean(r.effort, 10) || '-',
321      fmtElapsed((r.endedAt ?? now) - r.startedAt),
322      `${fmtTokens(inTokens(r))}/${fmtTokens(r.output)}`,
323      ...(priced ? [cost === null ? '-' : fmtCost(cost)] : []),
324      clean(r.description, DESC_MAX),
325    ]
326    const indent = r.parentId !== null && ids.has(r.parentId) ? '  ' : ''
327    return { text: `${indent}${cols.join('  ').trimEnd()}`, isDone: isSettled(r) }
328  })
329}
330
types/index.d.ts 28 lines
1export type DelegationBoardState = 'pending' | 'running' | 'waiting' | 'idle' | 'completed' | 'failed' | 'killed' | 'unknown'
2
3export type DelegationBoardRow = {
4  id: string
5  parentId: string | null
6  type: string
7  model: string
8  effort: string | null
9  state: DelegationBoardState
10  description: string
11  background: boolean
12  isTeammate: boolean
13  startedAt: number
14  endedAt: number | null
15  turns: number
16  input: number
17  output: number
18  cacheRead: number
19  cacheWrite: number
20  misses: number
21}
22
23declare module 'claude-code' {
24  interface PluginState {
25    'delegation-board': { agents: DelegationBoardRow[]; tick: number; opened: boolean }
26  }
27}
28