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…

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.
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.
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.
Put your own mods in local/mods/<name>/ (gitignored) and install them with scripts/mods install <name> --local.
MIT
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.
hooks/register.tsx 264 lines1import { 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}
264hooks/board.ts 330 lines1import 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}
330types/index.d.ts 28 lines1export 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