[Experimental] Unattended Claude workers over ACP — a coordinator dispatches headless workers with an optional permission fence, an event log, file-based…

Personal marketplace for Claude Code plugins focused on developer productivity and Agent-First workflows.
Add this marketplace to your Claude Code:
/plugin marketplace add musingfox/cc-plugins
Visual output across the whole spectrum — inline chat shapes for short explanations, browser HTML for long documents:
viz-render: handles three input shapes — file path, plan name, or inline content (markdown/mermaid)viz-inline: compact in-chat visuals (call trees, pseudocode, diff-on-shape, component trees) for short explanations and code-shape discussions; hands off to viz-render above ~20 lines (adapted from HumanLayer's show-me, MIT)~/.claude/plans/*.md; no argument → lists available plansInstallation:
/plugin install viz
Sandbox configuration: viz writes HTML output to /tmp/viz/. If sandbox is enabled, add to your .claude/settings.json or ~/.claude/settings.json:
{
"sandbox": {
"filesystem": {
"allowWrite": ["//tmp/viz"]
}
}
}
Fetch Apple Podcasts episode audio download URLs:
Installation:
/plugin install apple-podcasts
One skill that teaches a session to act as one node of a contract tree: take a contract from the layer above, deliver it, report back. It fires on its own for a goal whose scope is settled, and for a question not yet sure what to build:
contracts.json itself — observed channels, file scope, test cases, decision tiering, atomicity, load-bearing assumptions — and checks it with scripts/check-contracts.shcontracts.json reaches a muster worker through --ticket-file, never retyped; nested muster runs carry a depth-2 tree back in one fetchmethod:review checks a split node's integrated branch against the contract its author wrote; leaves run only the scope-gate.sh and revert-gate.sh gatesDONE or BLOCKED, then the kind, the review layer, what was found and the options, so a rex pane and a muster worker report alike; a problem climbs one layer at a time, after one information round and at most two bouncesrender-decision.sh, wait-decision.sh), with AskUserQuestion as the fallbackmethod:review, method:probe, method:prototype and method:divergence; scripts scope-gate.sh, revert-gate.sh, add-repros.sh, render-decision.sh, wait-decision.sh and check-contracts.shInstallation:
/plugin install method
Lifecycle management for MADR 4.0 Architecture Decision Records with cross-reference consistency enforcement:
Installation:
/plugin install adr
Invariants and forward-looking interface contracts as verifiable, sliceable entries:
slice <path>...: prints the entries constraining those paths, ready to paste into a worker's brief — a spec nobody reads is a document, one that injects itself is a constraintverify: runs every accepted entry's check; a check that would false-positive is downgraded to prose and reported as debt every run/spec:glossary keeps a living glossary of domain terms in CONTEXT.md at the repo rootInstallation:
/plugin install spec
Browser automation and Playwright test authoring:
agent-browser: live browser control via the agent-browser CLI — open URLs, snapshot pages, click/fill/screenshot, inspect elements. Ref-based interaction (@e1, @e2) from the accessibility tree instead of fragile CSS selectorsplaywright: write or set up @playwright/test E2E tests — specs, locators, assertions, fixtures, configweb-test: debug a live page and convert the findings into Playwright regression tests — the debug-to-test workflowInstallation:
/plugin install agent-browser
A visible browser agents drive and you can take over, beside your everyday Zen (macOS, Zen, bun, Node for npx):
zen-agent start | status | stop | open URLzen-agent start --headless, and zen-agent headed | headless to switch by restarting; tabs come backfirefox-devtools-mcp in connect-existing mode — snapshot, click, fill, screenshotzen-agent run SCRIPT.ts runs an agent-written puppeteer script over WebDriver BiDi in one call and prints only what it returns; installs puppeteer-core on first usezen-agent cookies DOMAIN copies one domain's cookies from your everyday profile; never pre-approvedzen-agent setup-shell makes an agent's open URL / $BROWSER URL in Rex shells land in the agent Zen; human shells are untouched. Re-run it after a plugin updateInstallation:
/plugin install zen-agent
One-stop hook setup assistant for Claude Code projects:
.githooks/pre-commit with security checks (secrets, private keys, sensitive files), file integrity checks (large files, merge conflicts, line endings), and structure checks (no-commit markers, syntax validation, lock sync).githooks/commit-msg validationcore.hooksPath pointing to .githooks/ (committed to repo)setup (detect + generate), doctor (health check), update (diff + refresh)Installation:
/plugin install hook-guard
Interact with Fizzy via the Fizzy CLI for project management:
--dry-run supportfizzy CLI installed and authenticated (fizzy setup)Installation:
/plugin install fizzy
Personal Obsidian vault productivity — capture, notes, and project management through the Obsidian CLI:
/issue): Claude Mod pane for listing a view of the project's dashboard and reading a card's details — coloured status and priority labels, an Acceptance Criteria count, Mermaid blocks drawn as text diagrams (uvx termaid), and an Open in browser Button that renders the card through viz, plus Bind this session / Unbind this session Buttons that tie the session to the card and show it in a band line above the prompt/obw:jot): Quick capture (timestamped journal bullet) or long-form note — triages by input shape; filename strategies (title / slug / timestamp-title), --folder overrides per-call/obw:pm): Task/doc lifecycle, Bases dashboards, wikilink cross-references; split a spec into blocking tickets/obw:init): Guided setup of .obsidian.yaml — vault binding, note/pm sectionsmcp__obw__pm_tick, pm_note, pm_status): Claude ticks an acceptance criterion, appends a Notes line or sets a card's status in one tool call; each writes through one obsidian eval and reads the card back.obsidian.yaml config in project root; the Issue Pane also needs Claude Code 2.1.287 or later, with uv and viz optionalInstallation:
/plugin install obsidian-workspace
Offload heavy work to pi cheap/fast models so Claude only writes briefs and reviews summaries — saving tokens:
agents/builder.md: brief-driven executor — when the brief embeds pi-agent.sh offload usage, builder operates it as a pure operator (pi-agent.sh start per task, pi-agent.sh watch as the main loop, run acceptance check, distill report); when the brief carries no offload usage, builder does the work itself. Builder does NOT choose the mode — the brief does.agents/reviewer.md: independent contract judge — given ONLY the contract, the deliverable paths, and the check output, returns an evidence-backed PASS/FAIL per clause; never sees the builder transcript, never runs offload verbs. Main dispatches builder and reviewer directly (no intermediary coordinator).pi-agent.sh: name-addressed unified verbs (start/send/poll/peek/ls/stop/watch) mirroring native sub-agent UX; send resumes a finished worker's session, watch feeds the Monitor tool for push notificationspi-dispatch.sh: launches one brief on a cheap/fast pi model in the background (routed by one PI_DISPATCH_CMD), returns a run handle instantly — dispatch N briefs for parallel fan-outpi-poll.sh / pi-stop.sh: idempotent one-line status polls and group-kill cancel; pi-worktree.sh isolates parallel code-writing tasks in git worktreespi CLI installed and authenticated (PI_DISPATCH_CMD selects another pi-compatible binary such as omp)Installation:
/plugin install pi-dispatch
Unattended Claude workers over ACP (claude-agent-acp, pinned by version): a coordinator dispatches headless workers, each optionally under a permission fence, and collects their result from a report file:
--model takes an alias (haiku, sonnet, opus, fable), since the adapter offers the newest models only under them, and the optional --effort sets how hard that model works; a value the adapter does not offer is refused with the offered values and their display namesQUESTION-<n>.json and the request blocks until ANSWER-<n>.json existsgit clone --no-hardlinks of your repo under the run directory, so nothing it runs can move your refs, as long as your repo is outside /tmp, /private/tmp and $TMPDIR (muster run refuses one under them); muster fetches its muster/<name> branch back when the worker goes idle and when the run ends, and cleanup is muster clean NAME, which removes the run directory of an exited run and refuses one whose branch could not be fetched back; muster status shows deps=<copied>/<total>, how many of the directories holding a lockfile had their dependencies copied into the clone, when the run records themBash runs inside Claude Code's OS sandbox: Bash writes are confined to the worktree and the system temp directories, plus a per-run tool cache so npm install and bun install work, and reads are not confined, except for credential stores (~/.npmrc, ~/.config/gh, ~/.git-credentials, ~/.config/git/credentials, ~/.netrc, ~/.aws, ~/.docker/config.json, ~/.ssh, ~/.pi, ~/.config/gcloud, ~/.kube, ~/.cargo/credentials.toml, ~/.pypirc, ~/Library/Keychains), which are denied. The list is best-effort: it does not follow symlink targets or env overrides such as XDG_CONFIG_HOME. Network for Bash and web fetch alike is limited to a built-in allowlist (registry.npmjs.org only; github.com is off it so a worker cannot push with your git credentials), extendable per dispatch through MUSTER_ALLOW_HOSTS; a host off the list, and any web search, is refused without a question. The worker's tools are a fixed list with claude.ai connectors off. muster run refuses a --repo or MUSTER_HOME under /tmp, /private/tmp or $TMPDIR, because the sandbox leaves those writable and a worker could then write the repository's refs or answer its own questions. muster run --unfenced drops the sandbox and leaves the clone as the only isolation, for a ticket whose checks the sandbox cannot run, such as a suite that starts its own sandbox-exec.
Installation:
/plugin install muster
Generate an interactive bash wizard that walks a human through steps only they can perform — ported from mattpocock/skills (wizard), MIT, Copyright (c) 2026 Matt Pocock:
.env upserts, gh secret/gh variable writesSTAGES markerInstallation:
/plugin install wizard
Confirm the cause of a bug and leave one failing test on a branch — ported from mattpocock/skills (diagnosing-bugs), MIT, Copyright (c) 2026 Matt Pocock:
/diagnose:diagnose asks before it starts; /diagnose:diagnose-now starts immediatelyInstallation:
/plugin install diagnose
Survey a codebase for deepening opportunities — shallow modules, leaking seams, interfaces that are hard to test through — and hand the candidates to viz as a Mermaid report — ported from mattpocock/skills (improve-codebase-architecture), MIT, Copyright (c) 2026 Matt Pocock:
method skill; this skill ends at the reportInstallation:
/plugin install deepen
Every omp provider's remaining quota inside the session, as a Claude Mod (needs Claude Code 2.1.287 or later):
/quota: toggles a compact table above the prompt, one line per provider with every window's share and time to reset/quota refresh: runs omp usage invalidate, then fetches fresh quota without a model turnokInstallation:
/plugin install omp-quota
Upcoming Google Calendar events inside the session, as a Claude Mod (needs Claude Code 2.1.287 or later and the claude.ai Google Calendar connector):
/cal: toggles a band above the prompt listing the next 7 days of events from every calendar you can see/cal N: lists the next N days (1–31) and remembers the choiceexclude_calendars: plugin option naming calendars to leave outInstallation:
/plugin install calendar
Mini games to play in a pane while Claude works, as a Claude Mod (tested on Claude Code 2.1.291); see games/README.md for how to play each:
/2048: opens a 2048 pane that takes the keys at once, even mid-turn; w a s d move, n starts over, q closes/mines: opens a 9×9 Minesweeper pane the same way; w a s d move the cursor, e digs, f flags/dino: opens Chrome's offline dino runner with cacti and birds; click the pane once, then space or ↑ jumps and ↓ ducks/gomoku solo: Gomoku against the computer, offline/gomoku new / /gomoku join <code> / /gomoku match: Gomoku against a friend by room code, or whoever is waiting, through the shared relay at games-relay.musingfox.com; a toast says when it is your turnInstallation:
/plugin install games
Review cards generated from your repo's specs, ADRs and vault task cards, as a Claude Mod (see flashcards/README.md):
/cards build: sends up to 20 changed sources to a background Haiku in batches of 10; the deck is kept outside the repo/cards: opens a review pane that deals the due cards with a Leitner schedule, without model tokens; f flips, 1 2 3 rate, q closesInstallation:
/plugin install flashcards
Every ``` `mermaid ``` block Claude writes drawn as plain box art in place in the transcript, as a Claude Mod (needs Claude Code 2.1.287 or later); a fork of claude-mermaid (MIT):
/mermaid-inline ascii|lr on|off: plain ASCII art, and whether top-down diagrams may be laid out sideways/mermaid-inline open shows it in the browserInstallation:
/plugin install mermaid-inline
Repository audits that put each piece of knowledge where it cannot drift. Each audit is a skill you type by hand (disable-model-invocation: true), so none costs context until you run it:
/audit:docs: sorts every section of the repo's docs — README.md, CONTRIBUTING.md, CLAUDE.md and AGENTS.md at every level, docs/, spec and ADR entries — into history, constraint, decision or current state; current state is a cache of the code, so it is replaced by a pointer to its source, rules move into the directory they govern, decisions go to /adr:adr and silent invariants to /spec:specREADME.md, CLAUDE.md); architecture, contributing, nested directories, UI design and operations are modules whose guidance loads only when the repo shows their signal, and a signal with no content gets a question, never an empty filedocs-classifier agent: classifies one long doc, samples its claims against the code, and writes a report; its output is a lead list the skill verifies before acting/audit:tests: traces each commitment clause of the repo's standing milestones in docs/milestones/ to the tests meant to guard it, and lists the clauses nothing guards, with one hand-off line per delivered milestone for the main session to get those tests written; it reads assertions and never runs the repo's teststests-tracer agent: traces one milestone's clauses and writes a verdict per clause to a report; the skill assembles the reports into one listInstallation:
/plugin install audit
This repository serves as both a marketplace and a development workspace for custom Claude Code plugins.
Every plugin directory carries .claude-plugin/plugin.json; the rest is its components.
Once per clone, run git config core.hooksPath .githooks. The hooks enforce Conventional Commit subjects and bump a plugin's patch version in the commit that changes it, so a single git push ships an update Claude Code will refresh.
cc-plugins/
├── .claude-plugin/marketplace.json # Marketplace configuration
├── .claude/skills/marketplace/ # Repo-internal: add/modify a plugin, version bump, sync
├── .githooks/ # commit-msg, pre-commit (version bump), post-commit, pre-push (guard)
├── adr/ skills: adr
├── agent-browser/ skills: agent-browser, playwright, web-test
├── apple-podcasts/ skills: apple-podcasts-fetch
├── audit/ skills: docs, tests · agents: docs-classifier, tests-tracer · scripts, tests
├── calendar/ hooks: register (Claude Mod) · tests
├── deepen/ skills: hooks/register.ts 84 lines1import { atom, read, update } from 'claude-code'
2import type { On } from 'claude-code'
3import type { BandRow, Board } from '../types/index.d.ts'
4import { readBoard } from './board.ts'
5import { STATE_COLOR, costText, elapsedText, progressCell, stateLabel, summaryParts } from './band-model.ts'
6
7export const board = atom({ plugin: 'muster', key: 'board' } as const, null as Board | null)
8
9const POLL_MS = 5000
10
11let timer: { cancel(): void } | null = null
12let polling = false
13
14// One read at a time: a tick that fires while a read is pending is skipped, and a read that never settles blocks only its own ticks.
15async function poll($: any) {
16 if (polling) return
17 polling = true
18 try {
19 const next = await readBoard($)
20 await update($, board, () => next)
21 } finally {
22 polling = false
23 }
24}
25
26// A run's name and title come from files, so they are drawn without control characters and capped in length.
27const drawable = (text: string): string => text.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, 10000)
28
29function rowOf(Box: any, Text: any, row: BandRow) {
30 const progress = progressCell(row)
31 return Box({
32 columnGap: 2,
33 children: [
34 Box({ width: 14, flexShrink: 0, children: [Text({ wrap: 'truncate-end', children: [drawable(row.name)] })] }),
35 Box({ width: 10, flexShrink: 0, children: [Text({ color: STATE_COLOR[row.state], children: [stateLabel(row.state)] })] }),
36 Box({ width: 36, flexShrink: 0, children: [Text({ wrap: 'truncate-end', children: [drawable(row.title)] })] }),
37 Box({ width: 10, flexShrink: 0, children: [Text({ ...(progress.dim ? { dimColor: true } : {}), children: [progress.text] })] }),
38 Text({ wrap: 'truncate-end', children: [Text({ dimColor: true, children: [elapsedText(row.seconds)] }), costText(row.cost)] }),
39 ],
40 })
41}
42
43async function drawBand($: any, e: any, beneath: unknown) {
44 const current = await read($, board)
45 if (!current) return beneath
46 const { Box, Text } = await $.ui.resolve(e)
47 const header = Text({
48 children: [Text({ weight: 'bold', children: ['muster'] }), Text({ dimColor: true, children: [` · ${summaryParts(current.rows, current.collect).join(' · ')}`] })],
49 })
50 return Box({
51 flexDirection: 'column',
52 children: [Box({ flexDirection: 'column', children: [header, ...current.rows.map((row) => rowOf(Box, Text, row))] }), beneath],
53 })
54}
55
56export function register(on: On) {
57 on('session.start', async ($, e, next) => {
58 // A reload re-fires session.start on this instance; a second timer would double the cadence.
59 timer?.cancel()
60 timer = $.clock.every(POLL_MS, () => {
61 void poll($).catch(() => {})
62 })
63 // Not awaited: a read that never settles must not hold the session's start.
64 void poll($).catch(() => {})
65 return next(e)
66 })
67
68 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
69 // A survey takes the prompt area, and an unreadable board or a failed draw leaves what the layers beneath drew.
70 if (e.props?.hasSurvey) return next(e)
71 try {
72 if (!(await read($, board))) return next(e)
73 } catch {
74 return next(e)
75 }
76 const beneath = await next(e)
77 try {
78 return await drawBand($, e, beneath)
79 } catch {
80 return beneath
81 }
82 })
83}
84types/index.d.ts 22 lines1export type RunState = 'starting' | 'running' | 'question' | 'idle' | 'done' | 'blocked' | 'malformed' | 'exited'
2
3export type LiveState = 'starting' | 'running' | 'question' | 'idle'
4
5export type BandRow = {
6 name: string
7 state: LiveState
8 title: string
9 plan: { done: number; total: number } | null
10 tools: number
11 cost: number | null
12 seconds: number
13}
14
15export type Board = { rows: BandRow[]; collect: number }
16
17declare module 'claude-code' {
18 interface PluginState {
19 muster: { board: Board | null }
20 }
21}
22hooks/board.ts 73 lines1import type { BandRow, Board, RunState } from '../types/index.d.ts'
2import { classifyRun, elapsedSeconds, figuresOf } from './band-model.ts'
3
4const LIVE: ReadonlySet<RunState> = new Set(['starting', 'running', 'question', 'idle'])
5const COLLECT: ReadonlySet<RunState> = new Set(['done', 'blocked', 'malformed'])
6
7const trimSlashes = (path: string): string => path.replace(/\/+$/, '')
8
9function readText($: any, path: string): Promise<string | null> {
10 return Promise.resolve($.fs.read(path)).then((text: unknown) => (typeof text === 'string' ? text : null), () => null)
11}
12
13function objectOf(text: string | null): Record<string, unknown> | null {
14 if (text === null) return null
15 try {
16 const value: unknown = JSON.parse(text)
17 return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null
18 } catch {
19 return null
20 }
21}
22
23// The directory muster writes runs to: MUSTER_HOME, or HOME's cache; an empty variable counts as unset.
24export async function runsRootOf($: any): Promise<string | null> {
25 try {
26 const muster = await $.env.get('MUSTER_HOME')
27 const home = await $.env.get('HOME')
28 const base = muster || (home ? `${home}/.cache/muster` : null)
29 return base ? `${base}/runs` : null
30 } catch {
31 return null
32 }
33}
34
35// Reads meta.json first and drops another repo's run before touching anything else. Null skips the run.
36async function runOf($: any, dir: string, name: string, root: string, now: number): Promise<{ state: RunState; row: BandRow } | null> {
37 try {
38 const meta = objectOf(await readText($, `${dir}/meta.json`))
39 if (!meta || typeof meta.repoRoot !== 'string' || trimSlashes(meta.repoRoot) !== root) return null
40 const seconds = typeof meta.started === 'string' ? elapsedSeconds(meta.started, null, now) : null
41 const phase = objectOf(await readText($, `${dir}/phase.json`))
42 if (!phase || seconds === null) return null
43 const files = (await $.fs.list(dir)).map((entry: { name: string }) => entry.name)
44 const report = await readText($, `${dir}/REPORT.md`)
45 const state = classifyRun({
46 phase: typeof phase.phase === 'string' ? phase.phase : undefined,
47 files,
48 report: report === null ? undefined : report.split('\n')[0].trim(),
49 })
50 const figures = figuresOf(phase)
51 return { state, row: { name, state: state as BandRow['state'], title: figures.title, plan: figures.plan, tools: figures.tools, cost: figures.cost, seconds } }
52 } catch {
53 return null
54 }
55}
56
57// The runs of this repository in flight and how many wait to be collected; null when none is in flight. Never throws.
58export async function readBoard($: any): Promise<Board | null> {
59 try {
60 const runsRoot = await runsRootOf($)
61 if (runsRoot === null) return null
62 const root = trimSlashes(await $.session.root())
63 const now = await $.clock.now()
64 const entries: { name: string; kind: string }[] = await $.fs.list(runsRoot)
65 const runs = await Promise.all(entries.filter((entry) => entry.kind === 'dir').map((entry) => runOf($, `${runsRoot}/${entry.name}`, entry.name, root, now)))
66 const rows = runs.flatMap((run) => (run && LIVE.has(run.state) ? [run.row] : []))
67 if (rows.length === 0) return null
68 return { rows, collect: runs.filter((run) => run !== null && COLLECT.has(run.state)).length }
69 } catch {
70 return null
71 }
72}
73hooks/band-model.ts 102 lines1import type { BandRow, LiveState, RunState } from '../types/index.d.ts'
2
3export type RunFacts = { phase: string | undefined; files?: string[]; report?: string }
4
5const numbersOf = (files: string[], pattern: RegExp): string[] =>
6 files.flatMap((file) => {
7 const match = pattern.exec(file)
8 return match ? [match[1]] : []
9 })
10
11// A question is open while no ANSWER-n.json names its number; a temporary write matches neither pattern.
12const hasOpenQuestion = (files: string[]): boolean => {
13 const answered = new Set(numbersOf(files, /^ANSWER-(\d+)\.json$/))
14 return numbersOf(files, /^QUESTION-(\d+)\.json$/).some((n) => !answered.has(n))
15}
16
17// The band's own copy of the state rules, after src/status.ts. Three differences are deliberate: an open question
18// beats a BLOCKED report here (status.ts reports blocked), the band ignores fetched and pid, and the caller
19// trims the report's first line (board.ts) before it arrives here.
20export function classifyRun({ phase, files = [], report }: RunFacts): RunState {
21 if (phase !== 'exited' && hasOpenQuestion(files)) return 'question'
22 if (report === 'DONE') return 'done'
23 if (report === 'BLOCKED') return 'blocked'
24 if (report !== undefined) return 'malformed'
25 switch (phase) {
26 case 'starting':
27 case 'running':
28 case 'idle':
29 case 'exited':
30 return phase
31 case 'question':
32 return 'running'
33 default:
34 return 'exited'
35 }
36}
37
38// Minutes round to the nearest, and carry into hours so 3599 s reads 1h0m, never 60m.
39export function elapsedText(seconds: number): string {
40 if (seconds < 60) return `${seconds}s`
41 const minutes = Math.round(seconds / 60)
42 return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h${minutes % 60}m`
43}
44
45// Null when the start does not parse; an end that does not parse is treated the same way.
46export function elapsedSeconds(startedIso: string, endedIso: string | null, nowMs: number): number | null {
47 const started = Date.parse(startedIso)
48 const ended = endedIso === null ? nowMs : Date.parse(endedIso)
49 if (Number.isNaN(started) || Number.isNaN(ended)) return null
50 return Math.max(0, Math.round((ended - started) / 1000))
51}
52
53export type Figures = { title: string; plan: { done: number; total: number } | null; tools: number; cost: number | null }
54
55const isCount = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0
56
57// Whatever phase.json holds, the band draws these fallbacks instead of a bad value.
58export function figuresOf(phase: unknown): Figures {
59 const fields = typeof phase === 'object' && phase !== null ? (phase as Record<string, unknown>) : {}
60 const { title, plan, tools, cost } = fields
61 const shown = typeof plan === 'object' && plan !== null ? (plan as Record<string, unknown>) : {}
62 const counted = isCount(shown.done) && isCount(shown.total) && shown.total > 0
63 return {
64 title: typeof title === 'string' && title.trim() !== '' ? title : '(no title yet)',
65 plan: counted ? { done: Math.min(shown.done as number, shown.total as number), total: shown.total as number } : null,
66 tools: isCount(tools) ? tools : 0,
67 cost: typeof cost === 'number' && Number.isFinite(cost) ? cost : null,
68 }
69}
70
71export const stateLabel = (state: RunState): string => (state === 'question' ? '? question' : state)
72
73// The theme keys the band paints each state with.
74export const STATE_COLOR: Record<RunState, string> = {
75 starting: 'inactive',
76 running: 'suggestion',
77 question: 'warning',
78 idle: 'permission',
79 done: 'success',
80 blocked: 'error',
81 malformed: 'error',
82 exited: 'inactive',
83}
84
85export const progressCell = (row: Pick<BandRow, 'plan' | 'tools'>): { text: string; dim: boolean } =>
86 row.plan ? { text: `${row.plan.done}/${row.plan.total}`, dim: false } : { text: `${row.tools} tools`, dim: true }
87
88export const costText = (cost: number | null): string =>
89 cost === null ? '' : ` $${cost.toLocaleString('en-US', { useGrouping: false, minimumFractionDigits: 1, maximumFractionDigits: 1 })}`
90
91// The header's words for the rows on the band and the runs waiting to be collected; a zero count is left out.
92export function summaryParts(rows: { state: RunState | LiveState }[], collect: number): string[] {
93 const count = (...states: string[]) => rows.filter((row) => states.includes(row.state)).length
94 const parts = [
95 [count('starting', 'running'), 'running'],
96 [count('question'), 'question'],
97 [count('idle'), 'idle'],
98 [collect, 'to collect'],
99 ] as const
100 return parts.filter(([n]) => n > 0).map(([n, word]) => `${n} ${word}`)
101}
102