Bullswarm as a Claude Mod: live subscription meters for every installed coding-agent CLI above the prompt, quota-aware routing of Claude's own subagents to…

A Claude Mod is a Claude Code plugin whose behaviour is TypeScript running inside Claude Code's engine ("function hooks", early access, enabled with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1). This folder is that plugin.
The terminal dashboard is Bullswarm's main screen. This Mod is its read-only counterpart: the strip is the run list, and the pane shows the dashboard's Run, Step, and Usage/Pools view in the same meter colours. It does not show the dashboard's Home, Runs, Budget, Stats, History, Fleet, or Help pages, and deliberately exposes no [edit] or [install] action. The pane's bottom row is back (on a step), the workflow and standalone-task buttons, usage, and close. Navigation matches the dashboard's mouse rules: click any button, tab, run, or step, and use the wheel to scroll.
Workflow buttons keep their digit hotkeys. A standalone dispatch has its own task <8-character-tail> button without taking a workflow digit; selecting it opens that task, and back returns to the selected workflow. A task is promoted automatically only when no workflow is in flight.
The pane's usage button is available even when no workflow is running, so it always opens the read-only pools and rungs page. When the workflow list is empty but a live bullswarm run assignment is present, the empty pane opens with Single task in flight and shows that task's lane, pool and model, the tail of its task-file path (falling back to its project or working directory) and its elapsed time; the same in-flight refresh cadence keeps that elapsed value current. Workflow assignments remain part of the run views, while the empty pane only promotes the standalone source: "run" record.
What it adds beyond the packaged skill and the MCP server:
| Hook | What happens | |
|---|---|---|
engine.create | $.bullswarm (pools, refresh, run) for any other plugin | |
session.start | registers /bullswarm, reads the meters, runs and in-flight ledger; re-reads every 2 min, every 20 s while work is in flight | |
prompt.context | a context block naming every pool's surplus and every ongoing workflow run, re-sent only when a pool changes by a bucket or a run starts, ends or changes status; a run waiting at a gate or a loop out of rounds reads waiting at gate approve with the bullswarm workflow continue command that moves it | |
tool.call on Agent | a general-purpose, Explore or Plan subagent runs on the pool with the most surplus via bullswarm run --lane analyze --no-retry: read-only, one attempt, since a build run must change a file and the mod's process budget holds one attempt; the tool result carries the verified output plus a routing note | |
tool.call on Bash | a bullswarm run / bullswarm workflow goal result gets the verdict appended in the doctrine's words, with a run's checked answer (--answer-schema) or its failed answer check; a launched workflow is pointed at bullswarm workflow watch <id> --until trouble; a goal that parked at a gate names the gate and its continue command. A bullswarm workflow watch or workflow wait result gets a note when it shows a v3 run waiting at a gate or a loop out of rounds (with the command), a loop's round, or checked answers | |
command.run /bullswarm | pools, runs, pane, open <step> [run], on, off, refresh, routed | |
ui.render AbovePrompt | the strip: one header line (run count, dispatches in flight, route off when routing is off) with [w], which opens or closes the runs pane; then one row per ongoing workflow run (progress, the step each pool is running with elapsed/expected minutes, the goal) and one per standalone dispatch; a digit in front of a run row opens that run. Routing and refresh are /bullswarm on, off and refresh. The strip option picks runs (default), full (pool meter rows too) or off. Nothing is pinned under the prompt | |
ui.render Pane + ui.scroll | the runs pane, docked beside the transcript. Fixed at the top: the run's header (with first–last/total while scrolled) and [Top] [End]. Fixed at the bottom: the pool rows, the page's hint line, then a button per workflow and standalone task, usage, close. Under 20 body rows (a phone terminal, a short inline pane) the pane draws a compact layout: one header row, one nav row with [Top] [End] and no close (the frame's ✕ closes it), nothing else pinned; ctrl+x up grows an inline pane while it has focus. Between them the whole overview as bullswarm workflow tui draws it (Preflight with the accepted goal and the goal and plan files, every phase, Live workers with their latest activity, Next) from bullswarm workflow tui <id> --overview, with the pool meters last (each bar carries a white mark where the window's elapsed time falls, in the status line's colours). Pools ▸ opens a pools page: every meter window of every pool as a full-width bar with reset time and pace, the credit meter where there is one, and the rungs (model, reasoning and record per pool × tier) grouped by lane or by provider with two tabs; read-only, back returns even when no workflow is running. The mod owns that window: the wheel over the pane, and arrows, PgUp/PgDn, Home/End once the pane has focus (click it), move the middle only. Every workflow step row opens the dashboard's finished Step v2 grammar from compact step.schemaVersion: 2 JSON in bullswarm workflow action show <run> <step> --json; a task button reads the same schema from bullswarm workflow task show <taskId> --json. The mod resolves the selected attempt and its canonical activity while rendering the same rows. Both show one verdict header with the visible overview · detail toggle; the latest 10 turns (5 below 100 columns) and its click for detail fold line; the full turn-and-tool transcript in detail; structured result, task facts, and the dashboard cost rows. Phone order is result → activity → task → cost. The newest overview turn is selected and expanded until the reader chooses another; a running command shows its name and a one-second elapsed clock from the CLI projection. The mod maps good/running/failure, dim, bold and pool-series roles onto its meter palette. v toggles the view, clicking either top tab selects it, and clicking expands overview turns. If the task JSON read fails, the honest unavailable fallback remains. Other in-flight pages refresh every 20 s; an open running Step refreshes every four seconds. | |
tool.call mcp__bullswarm__route | a tool the mod registers so the model can switch auto-routing on or off when the person asks ("stop routing my subagents"); the same switch as `/bullswarm on | off` |
ui.close | keeps the strip's [w] label in step when the person closes the pane |
Pool names in the session context, strip, pane and usage page are display names: an account pool such as claude-code:acme shows as claude-code:a (first letter of the account slug, unless two accounts would collide). A core label set with bullswarm pools label <pool> <label> is used when present. The plugin option poolAliases (/config, or pluginConfigs in settings) overrides any name: claude-code:acme=cc:a,claude-code:initech=cc:i. The durable pool ids remain unchanged because bullswarm derives them from the account's config directory (~/.claude-acme) and the keychain entry is keyed by it.
Observing a run: press the digit shown in front of a run row in the strip (1 for the first run) from an empty prompt, click the row, or press [w], or type /bullswarm pane. Letter hotkeys (b, r, w) fire while the band has focus (click it, or ctrl+x tab); digits fire from an empty composer. Inside the pane, q closes it, r re-reads, and a digit switches runs.
Run it from source for one session:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir mods/bullswarm
Load it in every session with plain claude. The CLI does both halves, linking the folder into the skills directory (a plugin there auto-loads as bullswarm@skills-dir) and setting the early-access flag under env in ~/.claude/settings.json; integrate status reports both and integrate remove undoes them:
bullswarm integrate install --agents claude --yes
Or install it from the repository's plugin marketplace, which copies the mod into Claude's plugin cache (refresh it with claude plugin update) and still needs the flag set:
claude plugin marketplace add Bulls-Work/bullswarm claude plugin install bullswarm@bullswarm
By hand, the two halves are:
ln -sfn "$(npm root -g)/bullswarm/mods/bullswarm" ~/.claude/skills/bullswarm-mod
{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
Use one route: an installed marketplace copy takes precedence over a skills-dir link with the same name, and Claude says so at startup.
Typecheck against this build's own declarations: inside a session with the mod loaded, /plugin-types mods/types writes claude-code.d.ts (the engine API plus every built-in tool's input and result schema) next to this folder, where tsconfig.json includes it; mods/types/ is generated, not committed.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir mods/bullswarm -p "/plugin-types mods/types" npx -p typescript tsc -p mods/bullswarm/tsconfig.json
Known limits: bullswarm's content gate wants real substance (80+ characters after any "I'll read…" narration), so a delegate that answers in one short line is rejected and the call falls back to an in-session subagent; a delegate run is capped at 9 minutes by $.process.run; /bullswarm is owned by the packaged skill, so the mod answers only its own subcommands on it and hands anything else to the skill.
hooks/register.ts 1051 lines1import type { On, PluginOptions } from 'claude-code'
2
3import type {
4 Bullswarm,
5 BullswarmAssignment,
6 BullswarmPool,
7 BullswarmRun,
8 BullswarmRunArgs,
9 BullswarmRung,
10 BullswarmRunDetail,
11 BullswarmStep,
12 BullswarmVerdict,
13} from '../types'
14import type { Host } from './host'
15import {
16 contextSignature,
17 contextText,
18 parsePools,
19 parseRungs,
20 poolLine,
21} from './pools'
22import { argvOf, decide, type AgentArgs } from './route'
23import { aliasesOf, displayNames, withDisplayNames } from './names'
24import { parseOverview, type OverviewLine } from './overview'
25import { type PaneScroll, paneView } from './pane'
26import {
27 parseAssignmentRecord,
28 parseAssignments,
29 parseDetail,
30 parseRuns,
31 parseStep,
32 paneChoice,
33 runLine,
34 standaloneTasks,
35 type BullswarmAssignmentRecord,
36} from './runs'
37import { strip } from './strip'
38import type { StepMode } from './step'
39import { parseVerdict, routedContext, verdictContext, watchContext } from './verdict'
40
41const COMMAND = 'bullswarm'
42const PANE_ID = 'bullswarm'
43/** Body rows the pane asks for while inline above the prompt. */
44const PANE_ROWS = 40
45const PANE_TITLE = 'bullswarm runs'
46const STORE_AUTO_ROUTE = 'bullswarm.autoRoute'
47/** The words the mod answers on `/bullswarm`; anything else is the skill's. */
48const SUBCOMMANDS = new Set(['pools', 'status', 'on', 'off', 'refresh', 'routed', 'runs', 'pane'])
49/** Refresh period while nothing is in flight, and while something is. */
50const IDLE_REFRESH_MS = 120_000
51const BUSY_REFRESH_MS = 20_000
52const STEP_REFRESH_MS = 4_000
53const STEP_TICK_MS = 1_000
54const POOLS_TIMEOUT_MS = 30_000
55/**
56 * `bullswarm run --timeout`, seconds: under the 10-minute cap on `$.process.run`.
57 * The process budget holds one attempt, which is why the run gets `--no-retry`
58 * (route.ts argvOf).
59 */
60const RUN_TIMEOUT_SEC = 540
61const RUN_PROCESS_MS = 585_000
62/** Rows asked of the overview frame, so the timeline is never windowed. */
63const FULL_FRAME_ROWS = 400
64/** How much of a step's output file the pane shows. */
65const OUTPUT_TAIL_CHARS = 1_800
66/** Lines of the task file the step view shows, as the TUI's agent panel does. */
67const PROMPT_PREVIEW_LINES = 7
68
69type Routed = {
70 at: number
71 description: string
72 lane: string
73 pool: string | null
74 model: string | null
75 outFile: string | null
76 ok: boolean
77 why: string | null
78}
79
80const messageOf = (error: unknown): string =>
81 error instanceof Error ? error.message : String(error)
82
83type RuntimeGlobal = typeof globalThis & {
84 process?: { env?: Record<string, string | undefined> }
85}
86
87/** The same home override the Bullswarm CLI uses for its ledger. */
88function assignmentPath(id: string): string | null {
89 if (!id || /[\\/]/.test(id)) return null
90 const env = (globalThis as RuntimeGlobal).process?.env ?? {}
91 const home = env.BULLSWARM_HOME?.trim() || (env.HOME ? `${env.HOME}/.bullswarm` : '')
92 return home ? `${home.replace(/\/+$/, '')}/assignments/${id}.json` : null
93}
94
95/**
96 * The CLI view contains the timing projection used by the strip. For a
97 * standalone `bullswarm run`, read its ledger file too so the empty pane can
98 * show optional project/task metadata without assuming an old record schema.
99 */
100async function readRunAssignmentFiles(h: Host, values: readonly BullswarmAssignmentRecord[]): Promise<BullswarmAssignmentRecord[]> {
101 const candidates = values.filter(a => a.source === 'run' && !!a.id)
102 if (!candidates.length) return [...values]
103 const direct = await Promise.all(
104 candidates.map(async a => {
105 const path = assignmentPath(a.id!)
106 if (!path) return a
107 try {
108 const raw = JSON.parse(await h.read(path)) as unknown
109 return parseAssignmentRecord(raw, a) ?? a
110 } catch {
111 return a
112 }
113 }),
114 )
115 const byId = new Map(direct.flatMap(a => (a.id ? [[a.id, a] as const] : [])))
116 return values.map(a => (a.id ? byId.get(a.id) ?? a : a))
117}
118
119/**
120 * Registers the mod: `$.bullswarm` in the engine.create fold, `/bullswarm`
121 * and the meter refresh at session.start, the pools in the prompt context,
122 * Agent calls routed through `bullswarm run`, the verdict on Bash calls
123 * that ran bullswarm, and the meter strip above the prompt.
124 *
125 * @param on the engine's registrar
126 */
127export function register(on: On, options: PluginOptions = {}) {
128 const aliases = aliasesOf(options.poolAliases)
129 const stripLevel: 'runs' | 'full' | 'off' =
130 options.strip === 'full' || options.strip === 'off' ? options.strip : 'runs'
131 let names = new Map<string, string>()
132 let host: Host | null = null
133 let cwd = ''
134 let depth: string | undefined
135 let pools: BullswarmPool[] = []
136 let runs: BullswarmRun[] = []
137 let assignments: BullswarmAssignment[] = []
138 let nowMs = Date.now()
139 let timerGeneration = 0
140 let stepTickGeneration = 0
141 let paneOpen = false
142 let selectedShortId: string | null = null
143 let selectedTaskId: string | null = null
144 let detail: BullswarmRunDetail | null = null
145 let detailError: string | null = null
146 let overview: OverviewLine[] | null = null
147 let overviewReadAt: number | null = null
148 let selectedActionId: string | null = null
149 let outputTail: string | null = null
150 let paneColumns = 100
151 let paneRows = 30
152 /** The body rows the pane really has, learned from the engine's report (see the render hook). */
153 let paneWant: number | null = null
154 /** The overview's own window: the first scrollable row shown, and its extent. */
155 let paneOffset = 0
156 /** The open step as `action show` reports it, and the head of its task file. */
157 let step: BullswarmStep | null = null
158 let stepMode: StepMode = 'overview'
159 let expandedStepTurn: number | null = null
160 /** The pool × tier rungs, read while the pane is open; the pane's pools section expands to show them. */
161 let rungs: BullswarmRung[] = []
162 /** The pane shows the pools page instead of the run while this is set. */
163 let poolsPage = false
164 /** How the usage page groups the rungs: by effort tier (lane) or by pool. */
165 let rungsBy: 'tier' | 'pool' = 'tier'
166 let promptPreview: string[] = []
167 let paneScroll: PaneScroll | null = null
168 let autoRoute = true
169 let inflight = 0
170 let lastError: string | null = null
171 let ready: Promise<unknown> = Promise.resolve()
172 let signature = ''
173 let own: { kind: string; percentUsed: number }[] = []
174 const routed: Routed[] = []
175
176 function stepActivity(): Record<string, unknown> | null {
177 const page = (step as unknown as { page?: Record<string, unknown> | null } | null)?.page
178 const presentation = page?.presentation
179 if (!presentation || typeof presentation !== 'object' || Array.isArray(presentation)) return null
180 const activity = (presentation as Record<string, unknown>).activity
181 return activity && typeof activity === 'object' && !Array.isArray(activity)
182 ? activity as Record<string, unknown>
183 : null
184 }
185
186 function runningStepOpen(): boolean {
187 const choice = paneChoice(runs, assignments, selectedShortId, selectedTaskId)
188 return paneOpen && Boolean(selectedActionId || choice?.kind === 'task') && stepActivity()?.running === true
189 }
190
191 /** Tick elapsed command time from the engine clock; never refetch for a tick. */
192 function syncStepTick(h: Host) {
193 const generation = ++stepTickGeneration
194 const activity = stepActivity()
195 if (!runningStepOpen() || !activity?.runningCommand) return
196 const tick = () => h.after(STEP_TICK_MS, async () => {
197 if (generation !== stepTickGeneration || !runningStepOpen() || !stepActivity()?.runningCommand) return
198 nowMs = await h.now()
199 h.invalidate('ui.render')
200 tick()
201 })
202 tick()
203 }
204
205 async function readPools(h: Host): Promise<BullswarmPool[]> {
206 const r = await h.run(['bullswarm', 'pools', '--json'], { timeoutMs: POOLS_TIMEOUT_MS })
207 if (r.exitCode !== 0)
208 throw new Error(r.stderr.trim().split('\n')[0] || `pools exited ${r.exitCode}`)
209 return parsePools(r.stdout)
210 }
211
212 async function readJson<T>(h: Host, argv: readonly string[], parse: (s: string) => T): Promise<T> {
213 const r = await h.run(argv, { timeoutMs: POOLS_TIMEOUT_MS })
214 if (r.exitCode !== 0)
215 throw new Error(r.stderr.trim().split('\n')[0] || `${argv[1]} exited ${r.exitCode}`)
216 return parse(r.stdout)
217 }
218
219 /**
220 * `action show` can exceed Claude's process-output limit because the Step
221 * record intentionally carries the complete transcript. Keep that exact
222 * model, but let the child split it into sub-limit files inside a validated
223 * temporary directory instead of parsing truncated stdout.
224 */
225 async function readLargeJson<T>(h: Host, argv: readonly string[], parse: (s: string) => T): Promise<T> {
226 const made = await h.run(['mktemp', '-d', '-t', 'bullswarm-step'], { timeoutMs: POOLS_TIMEOUT_MS })
227 const dir = made.stdout.trim()
228 if (made.exitCode !== 0 || !/^\/(?:private\/)?(?:tmp|var\/folders)\/(?:.+\/)?bullswarm-step\.[A-Za-z0-9]+$/.test(dir)) {
229 throw new Error(made.stderr.trim().split('\n')[0] || 'could not create a safe Step transport directory')
230 }
231 try {
232 const shown = await h.run(
233 ['/bin/sh', '-c', 'bullswarm "$@" > "$BULLSWARM_STEP_DIR/step.json" && /usr/bin/split -b 3000000 "$BULLSWARM_STEP_DIR/step.json" "$BULLSWARM_STEP_DIR/part-"', 'bullswarm-step', ...argv.slice(1)],
234 { timeoutMs: POOLS_TIMEOUT_MS, env: { BULLSWARM_STEP_DIR: dir } },
235 )
236 if (shown.exitCode !== 0) throw new Error(shown.stderr.trim().split('\n')[0] || `${argv[1]} exited ${shown.exitCode}`)
237 let text = ''
238 for (const first of 'abcdefghijklmnopqrstuvwxyz') {
239 for (const second of 'abcdefghijklmnopqrstuvwxyz') {
240 try { text += await h.read(`${dir}/part-${first}${second}`) }
241 catch { return parse(text) }
242 }
243 }
244 return parse(text)
245 } finally {
246 await h.run(['/bin/rm', '-rf', '--', dir], { timeoutMs: POOLS_TIMEOUT_MS }).catch(() => undefined)
247 }
248 }
249
250 async function refresh(h: Host): Promise<BullswarmPool[]> {
251 const [p, r, a, g] = await Promise.allSettled([
252 readPools(h),
253 readJson(h, ['bullswarm', 'workflow', 'runs', '--json'], parseRuns),
254 readJson(h, ['bullswarm', 'assignments', '--json'], parseAssignments),
255 paneOpen ? readJson(h, ['bullswarm', 'strategy', 'rungs', '--json'], parseRungs) : Promise.resolve(rungs),
256 ])
257 if (g.status === 'fulfilled') rungs = g.value
258 if (p.status === 'fulfilled') {
259 pools = p.value
260 }
261 if (r.status === 'fulfilled') runs = r.value
262 if (a.status === 'fulfilled') assignments = await readRunAssignmentFiles(h, a.value)
263 const identities = [...new Set([
264 ...pools.map(x => x.name),
265 ...assignments.map(x => x.pool),
266 ...rungs.map(x => x.pool),
267 ])]
268 const coreLabels = new Map<string, string>([
269 ...pools.flatMap(x => x.poolLabel && x.poolLabel !== x.name ? [[x.name, x.poolLabel] as const] : []),
270 ...assignments.flatMap(x => x.poolLabel && x.poolLabel !== x.pool ? [[x.pool, x.poolLabel] as const] : []),
271 ...rungs.flatMap(x => x.poolLabel && x.poolLabel !== x.pool ? [[x.pool, x.poolLabel] as const] : []),
272 ])
273 names = displayNames(identities, aliases, coreLabels)
274 const failed = [p, r, a].find((x): x is PromiseRejectedResult => x.status === 'rejected')
275 lastError = failed ? messageOf(failed.reason) : null
276 nowMs = await h.now()
277 if (selectedTaskId && !standaloneTasks(assignments).some(task => task.id === selectedTaskId)) {
278 selectedTaskId = null
279 step = null
280 detailError = null
281 paneOffset = 0
282 }
283 if (selectedShortId && !runs.some(r => r.shortId === selectedShortId)) {
284 // The selected run finished and left the ongoing list: keep its last
285 // detail on screen, fall to the newest run when one exists. The open
286 // step belonged to the old run, so it goes too — otherwise the pane
287 // asks the new run for a step it never had (`run "w89k6s" has no
288 // action "accept"`, seen 2026-09-17 when tc6cpi finished with its
289 // accept step open).
290 const next = runs[0]?.shortId ?? selectedShortId
291 if (next !== selectedShortId) {
292 selectedActionId = null
293 step = null
294 stepMode = 'overview'
295 expandedStepTurn = null
296 outputTail = null
297 promptPreview = []
298 paneOffset = 0
299 }
300 selectedShortId = next
301 }
302 if (paneOpen) await readDetail(h)
303 try {
304 const usage = await h.usage()
305 own = usage.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed }))
306 } catch {
307 own = []
308 }
309 settle(h)
310 return pools
311 }
312
313 async function readDetail(h: Host): Promise<BullswarmRunDetail | null> {
314 const choice = paneChoice(runs, assignments, selectedShortId, selectedTaskId)
315 if (!choice) {
316 detail = null
317 overview = null
318 step = null
319 syncStepTick(h)
320 return null
321 }
322 if (choice.kind === 'task') {
323 detailError = null
324 selectedActionId = null
325 step = null
326 promptPreview = []
327 outputTail = null
328 try {
329 step = await readLargeJson(h, ['bullswarm', 'workflow', 'task', 'show', choice.taskId, '--json'], text =>
330 parseStep(text, Date.now()),
331 )
332 } catch (error) {
333 detailError = messageOf(error)
334 }
335 syncStepTick(h)
336 schedule(h)
337 return detail
338 }
339 const id = choice.shortId
340 // A frame tall enough to hold the whole timeline: the pane scrolls it,
341 // so no level is hidden behind an "earlier rows" marker. The TUI keeps
342 // two columns for its own borders, which the parser strips, so asking
343 // for two more than the pane makes the text span the whole body.
344 const width = Math.max(40, paneColumns + 2)
345 const height = FULL_FRAME_ROWS
346 const [frame, shown] = await Promise.allSettled([
347 readJson(
348 h,
349 ['bullswarm', 'workflow', 'tui', id, '--overview', `--width=${String(width)}`, `--height=${String(height)}`],
350 text => parseOverview(text).map(l => ({ ...l, text: withDisplayNames(l.text, names) })),
351 ),
352 readJson(h, ['bullswarm', 'workflow', 'runs', 'show', id, '--json'], text =>
353 parseDetail(text, Date.now()),
354 ),
355 ])
356 if (frame.status === 'fulfilled') {
357 overview = frame.value
358 overviewReadAt = Date.now()
359 }
360 if (shown.status === 'fulfilled') detail = shown.value
361 const failed = [frame, shown].find((x): x is PromiseRejectedResult => x.status === 'rejected')
362 detailError = failed ? messageOf(failed.reason) : null
363
364 // The open step: its record and latest attempt as the TUI's agent panel
365 // shows them, the head of its task file, and the tail of its output.
366 step = null
367 promptPreview = []
368 outputTail = null
369 if (selectedActionId) {
370 try {
371 step = await readLargeJson(h, ['bullswarm', 'workflow', 'action', 'show', id, selectedActionId, '--json'], text =>
372 parseStep(text, Date.now()),
373 )
374 } catch (error) {
375 detailError = messageOf(error)
376 }
377 const taskFile = step?.attempt?.taskFile ?? null
378 if (taskFile) {
379 try {
380 const all = (await h.read(taskFile)).split(/\r?\n/).filter(l => l.trim())
381 promptPreview = all.slice(0, PROMPT_PREVIEW_LINES)
382 if (all.length > PROMPT_PREVIEW_LINES) promptPreview.push(`… ${String(all.length - PROMPT_PREVIEW_LINES)} more lines`)
383 } catch {
384 promptPreview = []
385 }
386 }
387 const outputFile = step?.outputFile ?? step?.attempt?.outputFile ?? null
388 if (outputFile) {
389 try {
390 const text = await h.read(outputFile)
391 outputTail = text.length > OUTPUT_TAIL_CHARS ? `…${text.slice(-OUTPUT_TAIL_CHARS)}` : text
392 } catch {
393 outputTail = null
394 }
395 }
396 }
397 syncStepTick(h)
398 schedule(h)
399 return detail
400 }
401
402 async function openPane(h: Host, shortId: string | null) {
403 if (shortId && shortId !== selectedShortId) selectedActionId = null
404 if (shortId) selectedTaskId = null
405 selectedShortId = shortId ?? selectedShortId ?? runs[0]?.shortId ?? null
406 // Seated inline (a phone terminal, a narrow window) the pane asks for
407 // most of the screen: the engine grants what the layout spares, and the
408 // dock beside a wide transcript ignores the request.
409 await h.openPane({ id: PANE_ID, title: PANE_TITLE, holdToasts: true, rows: PANE_ROWS })
410 paneOpen = true
411 settle(h)
412 await readDetail(h)
413 settle(h)
414 }
415
416 async function closePane(h: Host) {
417 await h.closePane({ id: PANE_ID }).catch(() => undefined)
418 paneOpen = false
419 expandedStepTurn = null
420 stepTickGeneration += 1
421 schedule(h)
422 settle(h)
423 }
424
425 /** Redraws what changed: the strip always, the prompt block only on a real change. */
426 function settle(h: Host) {
427 h.invalidate('ui.render')
428 const next = contextSignature(
429 pools,
430 autoRoute,
431 // A run that parks at a gate re-sends the block, so the model sees it wait.
432 runs.map(r => `${r.shortId}:${r.status}`),
433 )
434 if (next !== signature) {
435 signature = next
436 h.invalidate('prompt.context')
437 }
438 }
439
440 async function run(h: Host, args: BullswarmRunArgs): Promise<BullswarmVerdict> {
441 const argv = argvOf({
442 lane: args.lane,
443 cwd: args.cwd,
444 task: args.task,
445 timeoutSec: args.timeoutSec ?? RUN_TIMEOUT_SEC,
446 })
447 const r = await h.run(argv, { timeoutMs: RUN_PROCESS_MS, cwd: args.cwd })
448 return parseVerdict(r.stdout || r.stderr, r.exitCode)
449 }
450
451 /** Re-reads on a cadence that tightens while work is in flight. */
452 function schedule(h: Host) {
453 const generation = ++timerGeneration
454 const period = runningStepOpen()
455 ? STEP_REFRESH_MS
456 : assignments.length || runs.length ? BUSY_REFRESH_MS : IDLE_REFRESH_MS
457 h.after(period, () => {
458 if (generation !== timerGeneration) return
459 void refresh(h).finally(() => {
460 if (generation === timerGeneration) schedule(h)
461 })
462 })
463 }
464
465 async function setAutoRoute(h: Host, value: boolean) {
466 autoRoute = value
467 await h.storeSet(STORE_AUTO_ROUTE, value).catch(() => undefined)
468 settle(h)
469 }
470
471 on('engine.create', async ($, e, next) => {
472 const beneath = await next(e)
473
474 const built: Host = {
475 run: (argv, init) => beneath.process.run(argv, init),
476 usage: () => beneath.session.usage(),
477 read: path => beneath.fs.read(path),
478 storeSet: (key, value) => beneath.store.set(key, value),
479 after: (ms, fn) => beneath.clock.after(ms, fn),
480 now: () => beneath.clock.now(),
481 invalidate: event => beneath.ui.invalidate(event),
482 log: text => beneath.ui.log(text),
483 toast: text => beneath.ui.toast(text, { timeoutMs: 6_000 }),
484 openPane: pane => beneath.ui.open(pane),
485 closePane: pane => beneath.ui.close(pane),
486 }
487
488 const bullswarm: Bullswarm = {
489 pools: async () => (pools.length ? pools : refresh(host ?? built)),
490 refresh: () => refresh(host ?? built),
491 runs: async () => runs,
492 assignments: async () => assignments,
493 detail: async shortId => {
494 const h = host ?? built
495 const d = await readJson(h, ['bullswarm', 'workflow', 'runs', 'show', shortId, '--json'], text =>
496 parseDetail(text, Date.now()),
497 )
498 return d
499 },
500 run: args => run(host ?? built, args),
501 rungs: async () => readJson(host ?? built, ['bullswarm', 'strategy', 'rungs', '--json'], parseRungs),
502 step: async (shortId, actionId) => {
503 const h = host ?? built
504 return readLargeJson(h, ['bullswarm', 'workflow', 'action', 'show', shortId, actionId, '--json'], text =>
505 parseStep(text, Date.now()),
506 )
507 },
508 }
509
510 return { ...beneath, bullswarm }
511 })
512
513 on('session.start', async ($, e, next) => {
514 cwd = e.cwd
515 host = {
516 run: (argv, init) => $.process.run(argv, init),
517 usage: () => $.session.usage(),
518 read: path => $.fs.read(path),
519 storeSet: (key, value) => $.store.set(key, value),
520 after: (ms, fn) => $.clock.after(ms, fn),
521 now: () => $.clock.now(),
522 invalidate: event => $.ui.invalidate(event),
523 log: text => $.ui.log(text),
524 toast: text => $.ui.toast(text, { timeoutMs: 6_000 }),
525 openPane: pane => $.ui.open(pane),
526 closePane: pane => $.ui.close(pane),
527 }
528 const h = host
529 // Nothing pinned under the prompt: the strip and the pane carry the state.
530 $.ui.status(undefined)
531
532 depth = await $.env.get('BULLSWARM_DEPTH').catch(() => undefined)
533
534 const stored = await $.store.get(STORE_AUTO_ROUTE).catch(() => undefined)
535 if (typeof stored === 'boolean') autoRoute = stored
536
537 // Where the packaged skill already owns `/bullswarm`, the engine refuses
538 // the registration and the command.run hook below still answers the
539 // mod's subcommands on the skill's command.
540 try {
541 await $.command.register({
542 name: COMMAND,
543 description: 'Bullswarm meters and routing: pools · on · off · refresh · routed',
544 argumentHint: '[pools|on|off|refresh|routed]',
545 })
546 } catch (error) {
547 const reason = messageOf(error)
548 if (!/is the user's/.test(reason)) $.ui.log(`/${COMMAND} not registered: ${reason}`)
549 }
550
551 ready = refresh(h).finally(() => schedule(h))
552
553 // The person may ask Claude to stop or resume routing: a tool the model
554 // can call, the same switch as `/bullswarm on|off`.
555 await $.tool
556 .register({
557 name: 'route',
558 description:
559 'Switch bullswarm auto-routing of subagents on or off for this session, when the person asks. Off keeps every subagent in-session; on sends general-purpose subagents to the pool with spare quota. Report the resulting state.',
560 inputSchema: {
561 type: 'object',
562 properties: { on: { type: 'boolean', description: 'true to route, false to keep subagents in-session' } },
563 required: ['on'],
564 },
565 })
566 .catch(() => undefined)
567
568 return next(e)
569 })
570
571 on('prompt.context', async ($, e, next) => {
572 const result = await next(e)
573 if (depth) return result
574 await ready
575 if (pools.length === 0) return result
576 return {
577 blocks: [
578 ...result.blocks,
579 {
580 name: 'bullswarm',
581 text: withDisplayNames(contextText(
582 pools,
583 autoRoute,
584 routed.length,
585 runs.map(r => runLine(r, assignments, nowMs)),
586 ), names),
587 },
588 ],
589 }
590 })
591
592 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
593 const h = host
594 if (!autoRoute || depth || !h) return next(e)
595 await ready
596
597 const args = e as unknown as AgentArgs
598 const decision = decide(args, pools)
599 if (!decision.route) {
600 $.ui.log(`agent stays in-session: ${decision.reason}`)
601 return next(e)
602 }
603
604 const description =
605 typeof args.description === 'string' ? args.description : decision.task.slice(0, 60)
606
607 inflight += 1
608 settle(h)
609 $.ui.notice(
610 e.tool_use_id,
611 `bullswarm: routing this ${decision.lane} subagent to the pool with the most surplus…`,
612 )
613
614 let verdict: BullswarmVerdict
615 try {
616 verdict = await run(h, { lane: decision.lane, task: decision.task, cwd })
617 } catch (error) {
618 inflight -= 1
619 settle(h)
620 $.ui.notice(e.tool_use_id, `bullswarm: ${messageOf(error)} → running in-session`)
621 return next(e)
622 }
623 inflight -= 1
624
625 routed.push({
626 at: Date.now(),
627 description,
628 lane: decision.lane,
629 pool: verdict.pool,
630 model: verdict.model,
631 outFile: verdict.outFile,
632 ok: verdict.ok,
633 why: verdict.why,
634 })
635 void refresh(h)
636
637 if (!verdict.ok || !verdict.outFile) {
638 $.ui.notice(e.tool_use_id, `bullswarm: ${verdict.why ?? 'no verdict'} → running in-session`)
639 return next(e)
640 }
641
642 let output: string
643 try {
644 output = await $.fs.read(verdict.outFile)
645 } catch (error) {
646 $.ui.notice(
647 e.tool_use_id,
648 `bullswarm: output unreadable (${messageOf(error)}) → running in-session`,
649 )
650 return next(e)
651 }
652
653 $.ui.notice(
654 e.tool_use_id,
655 `bullswarm → ${verdict.pool ?? '?'} (${verdict.model ?? '?'}) · ${verdict.why ?? 'verified'}${verdict.notDone ? ` · worker left ${verdict.notDone.count} not done` : ''} · ${verdict.outFile}`,
656 )
657 $.ui.toast(`bullswarm routed "${description}" to ${verdict.pool ?? '?'}`, {
658 timeoutMs: 6_000,
659 })
660
661 const startedAt = routed[routed.length - 1]!.at
662 const elapsedMs = Math.max(0, Date.now() - startedAt)
663 const inputTokens = verdict.inputTokens ?? 0
664 const outputTokens = verdict.outputTokens ?? 0
665
666 // The Agent tool's own completed record, so the model's result channel
667 // validates: the delegate's output as the one text block, bullswarm's
668 // measured usage where it reports one.
669 return {
670 result: {
671 agentId: `bullswarm-${verdict.pool ?? 'pool'}-${startedAt.toString(36)}`,
672 agentType: `bullswarm:${verdict.pool ?? 'pool'}`,
673 content: [{ type: 'text', text: output }],
674 ...(verdict.model ? { resolvedModel: verdict.model, modelsUsed: [verdict.model] } : {}),
675 totalToolUseCount: 0,
676 totalDurationMs: verdict.wallSec !== null ? Math.round(verdict.wallSec * 1000) : elapsedMs,
677 totalTokens: inputTokens + outputTokens,
678 usage: {
679 input_tokens: inputTokens,
680 output_tokens: outputTokens,
681 cache_creation_input_tokens: null,
682 cache_read_input_tokens: null,
683 server_tool_use: null,
684 service_tier: null,
685 cache_creation: null,
686 },
687 status: 'completed',
688 prompt: decision.task,
689 },
690 context: [
691 routedContext(verdict, decision.lane),
692 ],
693 }
694 })
695
696 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
697 const command =
698 typeof (e as { command?: unknown }).command === 'string'
699 ? (e as { command: string }).command
700 : ''
701 const match =
702 /^(?:\s*[A-Z_][A-Z0-9_]*=\S*\s+)*(?:rtk\s+\S+\s+)?(?:\S*\/)?bullswarm\s+(run|workflow\s+goal|workflow\s+(?:watch|wait))\b/m.exec(
703 command,
704 )
705 if (!match) return next(e)
706
707 const result = await next(e)
708 const answered = result as {
709 deny?: string
710 isError?: true
711 result?: unknown
712 context?: readonly string[]
713 }
714 if (answered.deny !== undefined || answered.isError) return result
715
716 const watching = /^workflow\s+(?:watch|wait)/.test(match[1]!)
717 const verb = match[1]!.startsWith('run') ? 'run' : 'workflow goal'
718 // Bash's record: the verdict document is its stdout.
719 const record = answered.result as { stdout?: unknown; stderr?: unknown } | string | undefined
720 const text =
721 typeof record === 'string'
722 ? record
723 : typeof record?.stdout === 'string' && record.stdout.trim()
724 ? record.stdout
725 : typeof record?.stderr === 'string'
726 ? record.stderr
727 : ''
728 // A watch or wait prints lines, not a verdict: its note names where a
729 // v3 run waits, its loop's round and the answers it shows.
730 if (watching) {
731 const note = watchContext(text)
732 return note ? ({ ...result, context: [...(answered.context ?? []), note] } as typeof result) : result
733 }
734 const verdict = parseVerdict(text, 0)
735 const note = verdictContext(verb, verdict)
736
737 if (verb === 'run' && verdict.pool) {
738 routed.push({
739 at: Date.now(),
740 description: 'bullswarm run (Bash)',
741 lane: 'explicit',
742 pool: verdict.pool,
743 model: verdict.model,
744 outFile: verdict.outFile,
745 ok: verdict.ok,
746 why: verdict.why,
747 })
748 }
749 if (host) void refresh(host)
750
751 if (!note) return result
752 return { ...result, context: [...(answered.context ?? []), note] } as typeof result
753 })
754
755 on('command.run', { command: COMMAND }, async ($, e, next) => {
756 const arg = e.args.trim()
757 const h = host
758
759 // The packaged bullswarm skill owns bare `/bullswarm <task>`; the mod
760 // answers only its own words and hands everything else on.
761 const open = /^open\s+(\S+)(?:\s+(\S+))?$/.exec(arg)
762 if (open && h) {
763 // `/bullswarm open <step> [run]`: the pane on that step of the run.
764 const [, stepId, runId] = open
765 await openPane(h, runId ?? null)
766 const known = detail?.actions.map(a => a.id) ?? []
767 if (!known.includes(stepId!)) {
768 return {
769 text: known.length
770 ? `no step "${stepId!}" in ${selectedShortId ?? 'the run'}; steps: ${known.join(', ')}`
771 : `no action list yet for ${selectedShortId ?? 'the run'}`,
772 }
773 }
774 selectedActionId = stepId!
775 await readDetail(h)
776 settle(h)
777 return { text: `pane on ${selectedShortId ?? ''} · ${stepId!}` }
778 }
779 if (!SUBCOMMANDS.has(arg)) return next(e)
780
781 if (h && (arg === 'on' || arg === 'off')) {
782 await setAutoRoute(h, arg === 'on')
783 return { text: `bullswarm auto-route ${arg}` }
784 }
785 if (h && arg === 'refresh') await refresh(h)
786 if (h && arg === 'pane') {
787 await (paneOpen ? closePane(h) : openPane(h, null))
788 return { text: paneOpen ? `runs pane opened on ${selectedShortId ?? 'no run'}` : 'runs pane closed' }
789 }
790 if (arg === 'runs') {
791 const lines = runs.map(r => ` ${runLine(r, assignments, nowMs)}`)
792 const loose = assignments.filter(a => !a.runId || !runs.some(r => r.runId === a.runId))
793 for (const a of loose)
794 lines.push(` standalone ${a.lane} on ${a.pool}${a.model ? ` (${a.model})` : ''} ${a.elapsedMinutes ?? '?'}m elapsed`)
795 return {
796 text: withDisplayNames(lines.length
797 ? [`${runs.length} ongoing run(s), ${assignments.length} step(s) in flight:`, ...lines,
798 'bullswarm workflow watch <shortId> --until trouble wakes you when a run needs you; bullswarm workflow runs result <shortId> --json --summary reads its result'].join('\n')
799 : 'no ongoing workflow runs and nothing in flight', names),
800 }
801 }
802 if (arg === 'routed') {
803 return {
804 text: withDisplayNames(routed.length
805 ? routed
806 .map(
807 r =>
808 `${new Date(r.at).toISOString().slice(11, 19)} ${r.ok ? 'ok ' : 'no '} ${r.lane.padEnd(8)} ${r.pool ?? '-'} (${r.model ?? '-'}) ${r.description}${r.outFile ? ` → ${r.outFile}` : ''}${r.ok ? '' : ` · ${r.why ?? ''}`}`,
809 )
810 .join('\n')
811 : 'nothing routed this session', names),
812 }
813 }
814
815 const lines = [
816 `bullswarm mod · auto-route ${autoRoute ? 'on' : 'off'} · ${routed.length} routed this session${inflight ? ` · ${inflight} in flight` : ''}`,
817 ...(lastError ? [`meter read failed: ${lastError}`] : []),
818 ...pools.map(p => ` ${poolLine(p)}`),
819 ...runs.map(r => ` run ${runLine(r, assignments, nowMs)}`),
820 ...(own.length
821 ? [
822 ` this session: ${own
823 .map(l => `${l.kind} ${Math.round(l.percentUsed)}%`)
824 .join(', ')}`,
825 ]
826 : []),
827 '/bullswarm on|off toggles routing · /bullswarm refresh re-reads · /bullswarm runs shows ongoing workflows · /bullswarm pane opens the pane · /bullswarm open <step> [run] opens a step · /bullswarm routed lists routed calls · bare /bullswarm <task> is the skill',
828 ]
829 return { text: withDisplayNames(lines.join('\n'), names) }
830 })
831
832 on('tool.call', { tool: /^mcp__bullswarm__route$/ }, async ($, e) => {
833 const wanted = (e as { on?: unknown }).on === true
834 if (host) await setAutoRoute(host, wanted)
835 else autoRoute = wanted
836 return {
837 result: `bullswarm auto-route is now ${wanted ? 'on: general-purpose subagents route to the pool with spare quota' : 'off: subagents run in-session'} (the person can also type /bullswarm ${wanted ? 'off' : 'on'}).`,
838 }
839 })
840
841 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
842 if (stripLevel === 'off' || e.surface === 'mobile' || e.props.hasSurvey || pools.length === 0)
843 return next(e)
844
845 const { Box, Text, Button } = await $.ui.resolve(e)
846 const h = host
847
848 return strip(
849 { ui: { Box, Text, Button }, columns: e.props.bodyColumns, maxRows: e.props.maxRows },
850 {
851 pools,
852 runs,
853 assignments,
854 autoRoute,
855 routedCount: routed.length,
856 lastError,
857 paneOpen,
858 selectedShortId,
859 names,
860 level: stripLevel,
861 },
862 {
863 pane: () => {
864 if (!h) return
865 void (paneOpen ? closePane(h) : openPane(h, null))
866 },
867 open: shortId => {
868 if (!h) return
869 void (paneOpen && selectedShortId === shortId ? closePane(h) : openPane(h, shortId))
870 },
871 },
872 )
873 })
874
875 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
876 if (e.requestId !== PANE_ID || e.surface === 'mobile') return next(e)
877 const { Box, Text, Button } = await $.ui.resolve(e)
878 const h = host
879 const choice = paneChoice(runs, assignments, selectedShortId, selectedTaskId)
880 const selected = choice?.kind === 'workflow'
881 ? (runs.find(r => r.shortId === choice.shortId) ?? null)
882 : null
883 const selectedTask = choice?.kind === 'task'
884 ? (standaloneTasks(assignments).find(task => task.id === choice.taskId) ?? null)
885 : null
886
887 // The frame is sized to the pane; a resize re-reads at the new size.
888 // The engine reports the body as the lesser of the rows it granted and
889 // the rows the tree drew, so a tree sized to the report would only ever
890 // shrink. The tree carries blank probe rows past its footer; the report
891 // is then the grant itself, and the frame is laid out to that.
892 const reported = e.props.scroll.bodyRows
893 const grew = reported > 0 && reported !== paneWant
894 if (grew) paneWant = reported
895 const resized = paneColumns !== e.props.bodyColumns || paneRows !== reported
896 paneColumns = e.props.bodyColumns
897 paneRows = reported
898 const bodyRows = paneWant ?? Math.max(reported, 5)
899
900 // The engine still shows the pane across a module reload: adopt it, and
901 // read the frame the fresh environment does not have yet.
902 if ((!paneOpen || resized) && h) {
903 paneOpen = true
904 void readDetail(h).then(() => settle(h))
905 } else if (grew && h) {
906 settle(h)
907 }
908
909 const action = selectedActionId ? (detail?.actions.find(a => a.id === selectedActionId) ?? null) : null
910
911 const view = paneView(
912 { ui: { Box, Text, Button }, columns: e.props.bodyColumns },
913 {
914 runs,
915 selected,
916 selectedTask,
917 overview,
918 detail,
919 action,
920 step,
921 stepMode,
922 expandedStepTurn,
923 promptPreview,
924 outputTail,
925 pools,
926 assignments,
927 rungs,
928 poolsPage,
929 rungsBy,
930 readAt: overviewReadAt,
931 nowMs,
932 error: detailError,
933 names,
934 offset: paneOffset,
935 bodyRows,
936 },
937 {
938 select: shortId => {
939 paneOffset = 0
940 poolsPage = false
941 stepMode = 'overview'
942 expandedStepTurn = null
943 if (h) void openPane(h, shortId)
944 },
945 selectTask: taskId => {
946 selectedTaskId = taskId
947 selectedActionId = null
948 poolsPage = false
949 stepMode = 'overview'
950 expandedStepTurn = null
951 paneOffset = 0
952 if (h) {
953 settle(h)
954 void readDetail(h).then(() => settle(h))
955 }
956 },
957 openAction: actionId => {
958 selectedActionId = actionId
959 stepMode = 'overview'
960 expandedStepTurn = null
961 paneOffset = 0
962 if (h) {
963 settle(h)
964 void readDetail(h).then(() => settle(h))
965 }
966 },
967 back: () => {
968 const wasPoolsPage = poolsPage
969 poolsPage = false
970 if (!wasPoolsPage) {
971 selectedActionId = null
972 selectedTaskId = null
973 }
974 outputTail = null
975 step = null
976 stepMode = 'overview'
977 expandedStepTurn = null
978 paneOffset = 0
979 if (h) {
980 syncStepTick(h)
981 schedule(h)
982 settle(h)
983 }
984 },
985 toggleStep: () => {
986 stepMode = stepMode === 'overview' ? 'detail' : 'overview'
987 expandedStepTurn = null
988 paneOffset = 0
989 if (h) h.invalidate('ui.render')
990 },
991 setStepMode: mode => {
992 stepMode = mode
993 expandedStepTurn = null
994 paneOffset = 0
995 if (h) h.invalidate('ui.render')
996 },
997 expandStepTurn: turnIndex => {
998 expandedStepTurn = expandedStepTurn === turnIndex ? null : turnIndex
999 if (h) h.invalidate('ui.render')
1000 },
1001 setRungsBy: by => {
1002 rungsBy = by
1003 if (h) h.invalidate('ui.render')
1004 },
1005 openPools: () => {
1006 poolsPage = true
1007 paneOffset = 0
1008 if (!h) return
1009 h.invalidate('ui.render')
1010 if (!rungs.length) void refresh(h)
1011 },
1012 scrollTo: where => {
1013 paneOffset = where === 'start' ? 0 : Number.MAX_SAFE_INTEGER
1014 if (h) h.invalidate('ui.render')
1015 },
1016 close: () => {
1017 if (h) void closePane(h)
1018 },
1019 },
1020 )
1021 paneScroll = view.scroll
1022 if (view.scroll) paneOffset = view.scroll.offset
1023 return view.tree
1024 })
1025
1026 // The overview draws its own window under a fixed header and footer, so
1027 // the person's wheel and scroll keys move that window rather than the
1028 // engine's; the step view is left to the engine.
1029 on('ui.scroll', { requestId: PANE_ID }, async ($, e, next) => {
1030 if (!paneScroll) return next(e)
1031 const max = Math.max(0, paneScroll.contentRows - paneScroll.windowRows)
1032 paneOffset = Math.max(0, Math.min(max, paneOffset + e.by))
1033 $.ui.invalidate('ui.render')
1034 return {}
1035 })
1036
1037 on('ui.close', { id: PANE_ID }, async ($, e, next) => {
1038 const result = await next(e)
1039 if (result.deny === undefined) {
1040 paneOpen = false
1041 expandedStepTurn = null
1042 stepTickGeneration += 1
1043 if (host) {
1044 schedule(host)
1045 settle(host)
1046 }
1047 }
1048 return result
1049 })
1050}
1051hooks/host.ts 27 lines1import type {
2 PaneCloseArgs,
3 PaneOpenArgs,
4 ProcessRunInit,
5 ProcessRunResult,
6 SessionUsage,
7} from 'claude-code'
8
9/**
10 * The calls on `$` the mod's helpers make, each a closure written where
11 * `$` is in scope: the engine tracks side effects at the call site, so `$`
12 * itself is never passed around.
13 */
14export type Host = {
15 run: (argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult>
16 usage: () => Promise<SessionUsage>
17 read: (path: string) => Promise<string>
18 storeSet: (key: string, value: unknown) => Promise<void>
19 after: (ms: number, fn: () => void) => unknown
20 now: () => number | Promise<number>
21 invalidate: (event: 'ui.render' | 'prompt.context') => void
22 log: (text: string) => void
23 toast: (text: string) => void
24 openPane: (pane: PaneOpenArgs) => Promise<void>
25 closePane: (pane: PaneCloseArgs) => Promise<void>
26}
27hooks/pools.ts 225 lines1import type { BullswarmPool, BullswarmRung, BullswarmWindow } from '../types'
2
3type Raw = Record<string, unknown>
4
5const num = (v: unknown): number | null =>
6 typeof v === 'number' && Number.isFinite(v) ? v : null
7
8const str = (v: unknown): string | null => (typeof v === 'string' ? v : null)
9
10/**
11 * Reads `bullswarm pools --json` into the pool model, test fixtures left out.
12 *
13 * @param stdout the command's stdout
14 * @returns the pools, in bullswarm's order
15 */
16export function parsePools(stdout: string): BullswarmPool[] {
17 const doc = JSON.parse(stdout) as { pools?: Raw[] }
18 const raw = Array.isArray(doc.pools) ? doc.pools : []
19
20 return raw
21 .filter(p => p.testFixture !== true && typeof p.name === 'string')
22 .map(p => {
23 const q = p.quarantine as Raw | null | undefined
24
25 return {
26 name: p.name as string,
27 poolLabel: str(p.poolLabel),
28 enabled: p.enabled === true,
29 ...windowsOf(p, num(p.elapsedPct), str(p.pacingWindow)),
30 usedPct: num(p.usedPct),
31 elapsedPct: num(p.elapsedPct),
32 pace: num(p.pace),
33 pacingWindow: str(p.pacingWindow),
34 fiveHourUsedPct: num(p.fiveHourUsedPct),
35 costRank: num(p.costRank),
36 meterSource: str(p.meterSource),
37 meterError: str(p.meterError),
38 meterHoldUntil: num(p.meterHoldUntil),
39 quarantine:
40 q && typeof q === 'object'
41 ? {
42 until: num(q.until) ?? 0,
43 reason: str(q.reason) ?? '',
44 kind: str(q.kind) ?? 'unknown',
45 }
46 : null,
47 }
48 })
49}
50
51const WINDOW_MS: Record<string, number> = { '5h': 5 * 3600_000, '7d': 7 * 86_400_000 }
52
53/** The meter windows of one pool record, with elapsed% from each reset time. */
54function windowsOf(
55 p: Raw,
56 pacingElapsed: number | null,
57 pacingWindow: string | null,
58): Pick<BullswarmPool, 'windows' | 'capturedAt' | 'planType' | 'credits'> {
59 const snap = (p.meterSnapshot ?? null) as Raw | null
60 if (!snap) return { windows: [], capturedAt: null, planType: null, credits: null }
61 const capturedAt = str(snap.captured_at)
62 const nowMs = capturedAt ? Date.parse(capturedAt) : Date.now()
63 const windows: BullswarmWindow[] = []
64 const pacingKey = pacingWindow === 'monthly' ? 'mo' : pacingWindow === 'weekly' ? '7d' : pacingWindow === 'fiveHour' ? '5h' : null
65 for (const [key, field] of [['5h', 'five_hour'], ['7d', 'seven_day'], ['mo', 'monthly']] as const) {
66 const w = snap[field] as Raw | null | undefined
67 const used = w ? num(w.utilization) : null
68 if (used === null) continue
69 const resetsAt = w ? str(w.resets_at) : null
70 let elapsedPct: number | null = null
71 if (key === pacingKey && pacingElapsed !== null) elapsedPct = pacingElapsed
72 else if (resetsAt && WINDOW_MS[key]) {
73 const left = Date.parse(resetsAt) - nowMs
74 if (Number.isFinite(left)) elapsedPct = Math.max(0, Math.min(100, 100 - (left / WINDOW_MS[key]!) * 100))
75 }
76 windows.push({ key, usedPct: used, resetsAt, elapsedPct })
77 }
78 const quota = (snap.monthly_quota ?? null) as Raw | null
79 const credits =
80 quota && num(quota.used) !== null && num(quota.limit) !== null
81 ? { used: num(quota.used)!, limit: num(quota.limit)!, unit: str(quota.unit) ?? 'credits' }
82 : null
83 return { windows, capturedAt, planType: str(snap.plan_type), credits }
84}
85
86/** Reads `bullswarm strategy rungs --json`. */
87export function parseRungs(stdout: string): BullswarmRung[] {
88 const doc = JSON.parse(stdout) as { rungs?: Raw[] }
89 const raw = Array.isArray(doc.rungs) ? doc.rungs : []
90 return raw
91 .filter(r => typeof r.pool === 'string' && typeof r.tier === 'string')
92 .map(r => {
93 const reasoning = (r.reasoning ?? null) as Raw | null
94 const record = (r.record ?? null) as Raw | null
95 return {
96 pool: r.pool as string,
97 poolLabel: str(r.poolLabel),
98 tier: r.tier as string,
99 model: str(r.model),
100 reasoning: reasoning ? (str(reasoning.applied) ?? str(reasoning.requested)) : null,
101 dispatches: record ? (num(record.dispatches) ?? 0) : 0,
102 okShare: record ? num(record.okShare) : null,
103 medianMinutes: record ? num(record.medianMinutes) : null,
104 }
105 })
106}
107
108/** `+11.6` / `−4.5` / `n/a`. */
109export function paceText(p: BullswarmPool): string {
110 if (p.pace === null) return 'no meter'
111 const sign = p.pace >= 0 ? '+' : '−'
112 return `${sign}${Math.abs(p.pace).toFixed(1)}`
113}
114
115/** One word for the pool's state, and the color it draws in. */
116export function stateOf(p: BullswarmPool): { word: string; color: string } {
117 if (!p.enabled) return { word: 'disabled', color: 'gray' }
118 if (p.quarantine) return { word: 'quarantined', color: 'red' }
119 if (p.pace === null) return { word: 'unmetered', color: 'gray' }
120 if (p.pace >= 10) return { word: 'surplus', color: 'green' }
121 if (p.pace >= 0) return { word: 'on pace', color: 'cyan' }
122 return { word: 'over pace', color: 'yellow' }
123}
124
125/** A `[████░░░░░░]` bar of `width` cells for a percent. */
126export function barOf(pct: number | null, width: number): string {
127 if (pct === null) return '·'.repeat(width)
128 const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
129 return '█'.repeat(filled) + '░'.repeat(width - filled)
130}
131
132const hhmm = (ms: number): string => {
133 const d = new Date(ms)
134 const p = (n: number) => String(n).padStart(2, '0')
135 return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}Z`
136}
137
138/** One plain-text line per pool, for `/bullswarm` and the prompt context. */
139export function poolLine(p: BullswarmPool): string {
140 const state = stateOf(p).word
141 if (!p.enabled) return `${p.name}: disabled`
142 if (p.quarantine)
143 return `${p.name}: quarantined until ${hhmm(p.quarantine.until)} (${p.quarantine.kind})`
144 if (p.pace === null) return `${p.name}: enabled, no provider meter`
145 return `${p.name}: ${p.usedPct}% used, ${Math.round(p.elapsedPct ?? 0)}% of ${p.pacingWindow ?? 'window'} elapsed, ${state} ${paceText(p)}`
146}
147
148/**
149 * The block `prompt.context` adds: what bullswarm is, the pools, and what
150 * auto-route does, so the model knows its headroom without a tool call.
151 */
152export function contextText(
153 pools: readonly BullswarmPool[],
154 autoRoute: boolean,
155 routedCount: number,
156 runLines: readonly string[] = [],
157): string {
158 const lines = pools.filter(p => p.enabled).map(p => `- ${poolLine(p)}`)
159 const routing = autoRoute
160 ? 'Auto-route is ON: an Agent tool call whose subagent_type is general-purpose, claude, Explore or Plan (not forked, not background, no isolation) is offered to bullswarm first and, when a pool has spare quota, runs there read-only (the analyze lane of bullswarm, one attempt) instead of as a Claude subagent, so keep file edits in-session; its tool result then carries a "routed by bullswarm" note naming the pool, the model and the verified output file. Treat that output as evidence to check, never as authority. Anything bullswarm cannot place runs in-session as usual.'
161 : 'Auto-route is OFF: subagents run in-session. `/bullswarm on` turns routing back on.'
162
163 return [
164 'Bullswarm is loaded as a Claude Mod. Delegate pools now (surplus = window elapsed% − quota used%; positive is spare quota):',
165 ...lines,
166 ...(runLines.length
167 ? [
168 'Ongoing bullswarm workflow runs (`bullswarm workflow watch <shortId> --until trouble` wakes you when one waits at a gate, runs a loop out of rounds or has a step that needs you; `/bullswarm runs` shows progress):',
169 ...runLines.map(l => `- ${l}`),
170 ]
171 : []),
172 routing,
173 routedCount
174 ? `${routedCount} subagent call(s) were routed this session; \`/bullswarm routed\` lists them.`
175 : '',
176 '`/bullswarm pools` prints the meters. For explicit delegation use `bullswarm run` (one bounded task) or `bullswarm workflow goal` (a program), as the bullswarm skill describes; the mod appends the verdict to their Bash result.',
177 ]
178 .filter(Boolean)
179 .join('\n')
180}
181
182/**
183 * What the context block depends on, coarsely: a change here re-sends the
184 * block, so surplus is bucketed to keep the prompt cache warm.
185 */
186export function contextSignature(
187 pools: readonly BullswarmPool[],
188 autoRoute: boolean,
189 runIds: readonly string[] = [],
190): string {
191 return (
192 String(autoRoute) +
193 '|' +
194 runIds.join(',') +
195 '|' +
196 pools
197 .map(
198 p =>
199 `${p.name}:${p.enabled ? 1 : 0}:${p.quarantine ? 'q' : ''}:${
200 p.pace === null ? 'n' : Math.round(p.pace / 10)
201 }`,
202 )
203 .join(',')
204 )
205}
206
207/** The status line under the prompt. */
208export function statusText(
209 pools: readonly BullswarmPool[],
210 autoRoute: boolean,
211 inflight: number,
212 lastError: string | null,
213 names: ReadonlyMap<string, string> = new Map(),
214): string {
215 if (lastError) return `bullswarm: ${lastError}`
216 const best = pools
217 .filter(p => p.enabled && !p.quarantine && p.pace !== null)
218 .sort((a, b) => (b.pace ?? 0) - (a.pace ?? 0))[0]
219 const head = best
220 ? `most surplus: ${names.get(best.name) ?? best.name} ${paceText(best)}`
221 : 'no metered pool available'
222 const flight = inflight ? ` · ${inflight} routed run(s) in flight` : ''
223 return `bullswarm ${autoRoute ? 'auto-route on' : 'auto-route off'} · ${head}${flight}`
224}
225hooks/route.ts 83 lines1import type { BullswarmLane, BullswarmPool } from '../types'
2
3/** The Agent tool's arguments as the tool.call event carries them. */
4export type AgentArgs = {
5 prompt?: unknown
6 description?: unknown
7 subagent_type?: unknown
8 isolation?: unknown
9 run_in_background?: unknown
10 fork?: unknown
11 model?: unknown
12}
13
14/**
15 * Which bullswarm lane a subagent type maps to; a missing entry keeps it
16 * in-session. General subagents go to analyze: a build run must change a
17 * file (run-step.js), so a subagent that only answers would fail
18 * `not-produced` there, and a routed subagent's value is its answer.
19 */
20const LANE_OF: Record<string, BullswarmLane> = {
21 '': 'analyze',
22 'general-purpose': 'analyze',
23 claude: 'analyze',
24 Explore: 'analyze',
25 Plan: 'analyze',
26}
27
28export type RouteDecision =
29 | { route: true; lane: BullswarmLane; task: string }
30 | { route: false; reason: string }
31
32/**
33 * Decides whether an Agent call may leave the session: only plain subagent
34 * types, with a text prompt, not forked, not background, not isolated.
35 */
36export function decide(args: AgentArgs, pools: readonly BullswarmPool[]): RouteDecision {
37 const type = typeof args.subagent_type === 'string' ? args.subagent_type : ''
38 const lane = LANE_OF[type]
39 if (!lane) return { route: false, reason: `subagent_type ${type} stays in-session` }
40 if (typeof args.prompt !== 'string' || !args.prompt.trim())
41 return { route: false, reason: 'no prompt text' }
42 if (args.fork === true) return { route: false, reason: 'forked agents need this conversation' }
43 if (args.run_in_background === true)
44 return { route: false, reason: 'background agents report back later' }
45 if (typeof args.isolation === 'string' && args.isolation)
46 return { route: false, reason: `isolation ${args.isolation} is a session feature` }
47
48 const candidates = pools.filter(
49 p => p.enabled && !p.quarantine && p.name !== 'claude-code',
50 )
51 if (candidates.length === 0)
52 return { route: false, reason: 'no enabled, unquarantined delegate pool' }
53
54 return { route: true, lane, task: args.prompt }
55}
56
57/**
58 * The argv `$.process.run` spawns; no shell, the task inline. `--no-retry`:
59 * the mod's process budget holds one attempt, and a failed run already
60 * falls back to running the subagent in-session.
61 */
62export function argvOf(input: {
63 lane: BullswarmLane
64 cwd: string
65 task: string
66 timeoutSec: number
67}): string[] {
68 return [
69 'bullswarm',
70 'run',
71 '--lane',
72 input.lane,
73 '--add-dir',
74 input.cwd,
75 '--json',
76 '--no-retry',
77 '--timeout',
78 String(input.timeoutSec),
79 '--prompt',
80 input.task,
81 ]
82}
83hooks/names.ts 67 lines1/**
2 * Display names for pools: a `prefix:slug` account pool shows as
3 * `prefix:<first letter of slug>` unless two accounts would collide, and
4 * the plugin's `poolAliases` option (`from=to,from=to`) overrides any name.
5 */
6export function aliasesOf(option: unknown): Map<string, string> {
7 const map = new Map<string, string>()
8 if (typeof option !== 'string') return map
9 for (const pair of option.split(',')) {
10 const [from, to] = pair.split('=').map(s => s.trim())
11 if (from && to) map.set(from, to)
12 }
13 return map
14}
15
16export function displayNames(
17 pools: readonly string[],
18 aliases: ReadonlyMap<string, string>,
19 coreLabels: ReadonlyMap<string, string> = new Map(),
20): Map<string, string> {
21 const out = new Map<string, string>()
22 const taken = new Map<string, string>()
23 for (const name of pools) {
24 const alias = aliases.get(name)
25 if (alias) {
26 out.set(name, alias)
27 taken.set(alias, name)
28 }
29 }
30 for (const name of pools) {
31 if (out.has(name)) continue
32 const core = coreLabels.get(name)
33 if (core && !taken.has(core)) {
34 out.set(name, core)
35 taken.set(core, name)
36 continue
37 }
38 const at = name.indexOf(':')
39 const slug = at >= 0 ? name.slice(at + 1) : ''
40 let short = name
41 if (slug.length > 1) {
42 const prefix = name.slice(0, at)
43 const letter = `${prefix}:${slug[0]}`
44 const clash = pools.some(
45 other =>
46 other !== name &&
47 !aliases.has(other) &&
48 other.startsWith(`${prefix}:`) &&
49 other.slice(at + 1)[0] === slug[0],
50 )
51 short = clash || taken.has(letter) ? name : letter
52 }
53 out.set(name, short)
54 taken.set(short, name)
55 }
56 return out
57}
58
59/** Rewrites every full pool name in a text line to its display name. */
60export function withDisplayNames(text: string, names: ReadonlyMap<string, string>): string {
61 let out = text
62 for (const [full, short] of [...names.entries()].sort((a, b) => b[0].length - a[0].length)) {
63 if (full !== short) out = out.split(full).join(short)
64 }
65 return out
66}
67hooks/overview.ts 52 lines1/**
2 * One line of the run overview as bullswarm draws it, classified for the
3 * pane: a section header, a timestamped milestone, a detail line, or plain.
4 */
5export type OverviewLine = {
6 text: string
7 kind: 'header' | 'section' | 'milestone' | 'detail' | 'plain' | 'blank'
8 /** The right-aligned value bullswarm put at the end, when the row has one. */
9 tone: 'ok' | 'fail' | 'running' | 'none'
10}
11
12/**
13 * Reads the frame `bullswarm workflow tui <id> --overview` prints: the box
14 * border comes off (the pane draws its own), section titles become headers.
15 */
16export function parseOverview(stdout: string): OverviewLine[] {
17 const lines: OverviewLine[] = []
18 for (const raw of stdout.split('\n')) {
19 const line = raw.replace(/\s+$/, '')
20 if (!line) continue
21 if (/^[┌├]/.test(line)) {
22 const title = line.replace(/^[┌├]\s?/, '').replace(/[─┐┤]+$/, '').trim()
23 if (title) lines.push({ text: title, kind: 'header', tone: 'none' })
24 continue
25 }
26 if (/^└/.test(line)) continue
27 const inner = line.replace(/^│/, '').replace(/│$/, '').replace(/\s+$/, '')
28 if (!inner.trim()) {
29 if (lines.length && lines[lines.length - 1]!.kind !== 'blank')
30 lines.push({ text: '', kind: 'blank', tone: 'none' })
31 continue
32 }
33 const tone: OverviewLine['tone'] = /✓|✔/.test(inner)
34 ? 'ok'
35 : /✗|×|failed|blocked/.test(inner)
36 ? 'fail'
37 : /running|∴|◐|◑|◒|◓|⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏/.test(inner)
38 ? 'running'
39 : 'none'
40 const kind: OverviewLine['kind'] = /^── /.test(inner)
41 ? 'section'
42 : /^\d{2}:\d{2}\s/.test(inner)
43 ? 'milestone'
44 : /^\s{3,}/.test(inner) || /^\s*[↳↓↑]/.test(inner)
45 ? 'detail'
46 : 'plain'
47 lines.push({ text: inner, kind, tone })
48 }
49 while (lines.length && lines[lines.length - 1]!.kind === 'blank') lines.pop()
50 return lines
51}
52hooks/pane.tsx 693 lines1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import type { ElementTable, RenderElement } from 'claude-code'
5
6import type {
7 BullswarmAction,
8 BullswarmAssignment,
9 BullswarmPool,
10 BullswarmRun,
11 BullswarmRunDetail,
12 BullswarmRung,
13 BullswarmStep,
14} from '../types'
15import type { OverviewLine } from './overview'
16import { METER_AMBER, METER_GREEN, METER_RED, meterBar, poolRows, severityColor } from './pool-rows'
17import { ageOf, standaloneTasks } from './runs'
18import { shapeStep, taskStepPane, type StepMode, type StepPaneRow } from './step'
19
20export type PaneUi = Pick<ElementTable<'terminal' | 'desktop'>, 'Box' | 'Text' | 'Button'>
21
22export type PaneModel = {
23 runs: readonly BullswarmRun[]
24 selected: BullswarmRun | null
25 /** The standalone task explicitly selected, or promoted because no workflow exists. */
26 selectedTask: BullswarmAssignment | null
27 overview: readonly OverviewLine[] | null
28 detail: BullswarmRunDetail | null
29 /** The action the person opened, when one is open. */
30 action: BullswarmAction | null
31 /** That step as `action show` reports it, when read. */
32 step: BullswarmStep | null
33 /** Pane-local mode/turn state; the host may persist and update these. */
34 stepMode?: StepMode
35 expandedStepTurn?: number | null
36 /** The head of the step's task file, one entry per line. */
37 promptPreview: readonly string[]
38 /** The tail of that action's output file, when read. */
39 outputTail: string | null
40 pools: readonly BullswarmPool[]
41 assignments: readonly BullswarmAssignment[]
42 /** Every pool × tier rung, for the expanded pools section. */
43 rungs: readonly BullswarmRung[]
44 /** The pools page is open instead of the run. */
45 poolsPage: boolean
46 /** How that page groups the rungs. */
47 rungsBy: 'tier' | 'pool'
48 readAt: number | null
49 nowMs: number
50 error: string | null
51 names: ReadonlyMap<string, string>
52 /** The first scrollable row the overview shows; 0 at the top. */
53 offset: number
54 /** The rows the pane body has, as the engine drew it. */
55 bodyRows: number
56}
57
58/** How the overview's own window sits over its scrollable rows. */
59export type PaneScroll = {
60 offset: number
61 contentRows: number
62 windowRows: number
63}
64
65export type PaneActions = {
66 select: (shortId: string) => void
67 selectTask: (taskId: string) => void
68 openAction: (actionId: string) => void
69 /** Optional until the host wires the pane's v/Enter controls to state. */
70 toggleStep?: () => void
71 setStepMode?: (mode: StepMode) => void
72 expandStepTurn?: (turnIndex: number) => void
73 /** Moves the overview's window to its top or bottom. */
74 scrollTo: (where: 'start' | 'end') => void
75 /** Opens the pools page: every meter window and the model per tier. */
76 openPools: () => void
77 /** Groups the rung table by effort tier (lane) or by pool. */
78 setRungsBy: (by: 'tier' | 'pool') => void
79 back: () => void
80 close: () => void
81}
82
83/** The single-row Usage header, including the oldest failed meter reason. */
84export function poolsHeaderText(
85 pools: readonly BullswarmPool[],
86 nowMs: number,
87 nameOf: (pool: string | null) => string = pool => pool ?? '',
88): string {
89 const sampled = pools
90 .filter(p => !!p.capturedAt)
91 .sort((a, b) => Date.parse(a.capturedAt!) - Date.parse(b.capturedAt!))
92 const oldest = sampled[0] ?? null
93 const oldestError = sampled.find(p => !!p.meterError) ?? null
94 const shown = oldestError ?? oldest
95 if (!shown) return 'Pools · no meter snapshot yet'
96 const age = ageOf(shown.capturedAt, nowMs) || '0m'
97 return oldestError
98 ? `Pools · ${nameOf(shown.name)} sampled ${age} ago · ${oldestError.meterError}`
99 : `Pools · sampled ${age} ago`
100}
101
102/** Below this many body rows the pane draws its compact layout (a phone terminal, a short inline pane). */
103export const COMPACT_ROWS = 20
104/** Blank rows drawn past the footer so the engine reports the pane's true height. */
105const PROBE_ROWS = 8
106
107const toneColor = (tone: OverviewLine['tone']): string | undefined =>
108 tone === 'ok' ? 'green' : tone === 'fail' ? 'red' : tone === 'running' ? 'cyan' : undefined
109
110type AssignmentRecord = BullswarmAssignment & {
111 id?: string
112 startedAt?: string
113 project?: string
114 projectName?: string
115 cwd?: string
116 taskFile?: string
117 task?: string
118 description?: string
119}
120
121const pathTail = (value: string): string => value.split(/[\\/]/).filter(Boolean).pop() ?? value
122
123/** Best available project/task label from a live assignment record. */
124const assignmentWork = (a: BullswarmAssignment): string => {
125 const record = a as AssignmentRecord
126 const value = [record.taskFile, record.projectName, record.project, record.cwd, record.description, record.task]
127 .find(candidate => typeof candidate === 'string' && candidate.trim())
128 return value ? pathTail(value) : 'task unavailable'
129}
130
131/** Greedy word wrap to `width` cells, one string per row; long words are cut. */
132export function wrapText(text: string, width: number): string[] {
133 const w = Math.max(8, width)
134 const out: string[] = []
135 for (const raw of text.split(/\r?\n/)) {
136 let line = raw
137 if (!line.trim()) {
138 out.push('')
139 continue
140 }
141 while (line.length > w) {
142 let cut = line.lastIndexOf(' ', w)
143 if (cut < w * 0.5) cut = w
144 out.push(line.slice(0, cut))
145 line = line.slice(cut).replace(/^\s+/, '')
146 }
147 out.push(line)
148 }
149 return out
150}
151
152/** The action id a frame line names, when it is one of the run's actions. */
153function actionOf(text: string, actions: readonly BullswarmAction[]): BullswarmAction | null {
154 let best: BullswarmAction | null = null
155 for (const a of actions) {
156 const re = new RegExp(`(^|[\\s│├└─✓✗×·])${a.id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=$|[\\s·])`)
157 if (re.test(text) && (!best || a.id.length > best.id.length)) best = a
158 }
159 return best
160}
161
162/**
163 * The docked pane. Overview mode: the run's header and `[Top] [End]` stay
164 * put, the frame `bullswarm workflow tui --overview` draws scrolls under
165 * them (every step row a button that opens the step, the pools at the
166 * bottom), and the run switcher stays put beneath. The mod owns that
167 * window (`ui.scroll`), so the tree is never taller than the body. Step
168 * mode: that action's attempt, files and the tail of its output, which
169 * the engine scrolls.
170 */
171export function paneView(
172 kit: { ui: PaneUi; columns: number },
173 model: PaneModel,
174 actions: PaneActions,
175): { tree: RenderElement; scroll: PaneScroll | null } {
176 const { Box, Text, Button } = kit.ui
177 const run = model.selected
178 const rule = '─'.repeat(Math.max(1, Math.min(kit.columns, 120)))
179 const nameOf = (pool: string | null) => (pool ? (model.names.get(pool) ?? pool) : '')
180 const looseTask = model.selectedTask
181
182 // Compact: a phone terminal or a short inline pane. One header row, one
183 // nav row with [Top] [End] beside it and no [close] (the frame's ✕ does
184 // that), and nothing else pinned, so the window keeps most of the body.
185 // Pools stay one press away: [usage].
186 const compact = model.bodyRows < COMPACT_ROWS
187 const runButtons = model.runs.map((r, i) => (
188 <Button
189 key={`run-${r.shortId}`}
190 hotkey={i < 9 ? String(i + 1) : undefined}
191 label={run && r.shortId === run.shortId && !model.poolsPage ? `● ${r.shortId}` : r.shortId}
192 onPress={() => actions.select(r.shortId)}
193 />
194 ))
195 const taskButtons = standaloneTasks(model.assignments).map(task => {
196 const id = task.id!
197 const tail = id.length > 8 ? id.slice(-8) : id
198 const active = looseTask && (looseTask as AssignmentRecord).id === id && !model.poolsPage
199 return <Button key={`task-${id}`} label={`${active ? '● ' : ''}task ${tail}`} onPress={() => actions.selectTask(id)} />
200 })
201 const backButton = model.action || model.poolsPage || looseTask
202 ? <Button key="back" hotkey="b" label="back" onPress={actions.back} />
203 : null
204 const usageButton = (
205 <Button key="usage" hotkey="u" label={model.poolsPage ? '● usage' : 'usage'} onPress={actions.openPools} />
206 )
207 const closeButton = <Button key="close" hotkey="q" label="close" onPress={actions.close} />
208 const switcher = compact ? (
209 <Box key="switcher" flexDirection="row" gap={1} flexWrap="nowrap" overflow="hidden">
210 {backButton}
211 {runButtons}
212 {taskButtons}
213 {usageButton}
214 <Button key="top" plain label="[Top]" onPress={() => actions.scrollTo('start')} />
215 <Button key="end" plain label="[End]" onPress={() => actions.scrollTo('end')} />
216 </Box>
217 ) : (
218 <Box key="switcher" flexDirection="row" gap={1} flexWrap="nowrap" overflow="hidden">
219 {backButton}
220 {runButtons}
221 {taskButtons}
222 {usageButton}
223 {closeButton}
224 </Box>
225 )
226
227 const poolsSection = model.pools.length ? (
228 <Box key="pools" flexDirection="column">
229 <Text wrap="truncate-end">
230 <Text bold>Pools</Text>
231 <Text dimColor> · used/elapsed · pace {'─'.repeat(Math.max(0, Math.min(kit.columns, 120) - 30))}</Text>
232 </Text>
233 {poolRows({ Box, Text }, model.pools, model.assignments, model.names)}
234 </Box>
235 ) : null
236
237 // Both modes share one frame: fixed header rows, a window over `rows`
238 // the mod scrolls itself, fixed footer rows. One element per row keeps
239 // the window's arithmetic exact, so long text is wrapped here.
240 const width = Math.max(20, Math.min(kit.columns, 120))
241 const frame = (
242 header: RenderElement[],
243 rows: RenderElement[],
244 footer: RenderElement[],
245 ): { tree: RenderElement; scroll: PaneScroll } => {
246 const fixedRows = header.length + footer.length
247 const windowRows = Math.max(1, model.bodyRows - fixedRows)
248 const contentRows = rows.length
249 const offset = Math.max(0, Math.min(model.offset, Math.max(0, contentRows - windowRows)))
250 const shown = rows.slice(offset, offset + windowRows)
251 // Pad a short body so the footer always sits on the pane's last rows.
252 while (shown.length < windowRows) shown.push(<Text key={`pad${String(shown.length)}`}> </Text>)
253 // Blank rows past the footer, hidden below the body: they let the engine
254 // report the rows it granted rather than the rows the frame drew.
255 const probe = Array.from({ length: PROBE_ROWS }, (_, k) => <Text key={`probe${String(k)}`}> </Text>)
256 const scrolled = offset > 0 || contentRows > offset + windowRows
257 const position = scrolled ? ` · ${String(offset + 1)}–${String(offset + shown.length)}/${String(contentRows)}` : ''
258 return {
259 tree: (
260 <Box flexDirection="column">
261 {header.map((el, k) => (k === 0 ? <Box key="hdr" flexDirection="row">{el}<Text dimColor>{position}</Text></Box> : el))}
262 {shown}
263 {footer}
264 {probe}
265 </Box>
266 ),
267 scroll: { offset, contentRows, windowRows },
268 }
269 }
270 const nav = (
271 <Box key="nav" flexDirection="row" gap={1} flexWrap="nowrap">
272 <Button key="top" plain label="[Top]" onPress={() => actions.scrollTo('start')} />
273 <Button key="end" plain label="[End]" onPress={() => actions.scrollTo('end')} />
274 <Text dimColor wrap="truncate-end">
275 {' '}
276 wheel · arrows · PgUp/PgDn when the pane has focus
277 </Text>
278 </Box>
279 )
280 const dim = (key: string, text: string) => (
281 <Text key={key} dimColor wrap="truncate-end">
282 {text}
283 </Text>
284 )
285 const plain = (key: string, text: string, color?: string) => (
286 <Text key={key} wrap="truncate-end" color={color}>
287 {text}
288 </Text>
289 )
290 const blank = (key: string) => <Text key={key}> </Text>
291
292 if (model.poolsPage) {
293 const header: RenderElement[] = [
294 <Text key="h0" wrap="truncate-end">
295 <Text bold color="cyan">
296 Pools
297 </Text>
298 <Text dimColor>{poolsHeaderText(model.pools, model.nowMs, nameOf).slice('Pools'.length)}</Text>
299 </Text>,
300 ]
301 if (!compact) header.push(nav)
302 if (model.error) header.push(plain('err', model.error, 'red'))
303 // The note sits whole, wrapped, right above the nav, where it is read;
304 // compact panes carry it as the page's first rows instead.
305 const note = wrapText('read-only · adjust pools and rungs with `bullswarm setup` (or `bullswarm strategy`)', width)
306 const noteRows = note.map((l, k) => dim(`note${String(k)}`, l))
307 const pageRows = poolsPageRows({ Box, Text, Button }, model, width, nameOf, actions)
308 const footer: RenderElement[] = compact ? [switcher] : [...noteRows, switcher]
309 return frame(header, compact ? [...noteRows, ...pageRows] : pageRows, footer)
310 }
311
312 if (!run && !model.step && !looseTask) {
313 return {
314 tree: (
315 <Box flexDirection="column">
316 <Text key="empty" dimColor>No ongoing workflow run. `bullswarm workflow goal` launches one.</Text>
317 {model.error ? <Text key="idle-error" color={METER_RED}>{model.error}</Text> : null}
318 {poolsSection}
319 {switcher}
320 </Box>
321 ),
322 scroll: null,
323 }
324 }
325
326 if (model.action || model.step || looseTask) {
327 // The Step model is shaped without UI concerns. This branch only maps
328 // rows to the Claude engine's Text/Button elements.
329 const page = model.step
330 const assignment = looseTask as AssignmentRecord | null
331 const a = model.action ?? {
332 id: page?.id ?? assignment?.id ?? assignmentWork(assignment!),
333 status: page?.status ?? (assignment ? 'running' : 'unknown'),
334 attempts: page?.attempt ? 1 : 0,
335 startedAt: page?.attempt?.startedAt ?? null,
336 finishedAt: page?.attempt?.finishedAt ?? null,
337 outputFile: page?.outputFile ?? null,
338 lastFailure: page?.lastFailure ?? null,
339 latest: null,
340 }
341 const st = model.step
342 const stepOptions = {
343 mode: model.stepMode,
344 expandedTurn: model.expandedStepTurn,
345 promptPreview: model.promptPreview.length ? model.promptPreview : assignment?.description ? [assignment.description] : [],
346 outputTail: model.outputTail,
347 width,
348 nowMs: model.nowMs,
349 }
350 const shaped = st ?? page ? shapeStep(st ?? page, stepOptions) : taskStepPane(assignment, stepOptions)
351 const poolColor = (pool: string): string => {
352 const colors = ['#a99cf0', '#70b7c7', '#d596c7', '#86b98a', '#d39b72']
353 let hash = 0
354 for (const char of pool) hash = ((hash * 31) + char.charCodeAt(0)) >>> 0
355 return colors[hash % colors.length]!
356 }
357 const toneProps = (tone: StepPaneRow['tone'], pool?: string): { color?: string; bold?: boolean; dimColor?: boolean } => {
358 if (tone === 'good') return { color: METER_GREEN }
359 if (tone === 'bad') return { color: METER_RED }
360 if (tone === 'running') return { color: METER_AMBER }
361 if (tone === 'strong') return { bold: true }
362 if (tone === 'pool') return { color: poolColor(pool ?? '') }
363 if (tone === 'dim') return { dimColor: true }
364 return {}
365 }
366 const rowText = (entry: StepPaneRow): RenderElement[] => {
367 if (entry.toggle) {
368 // Rule 14: the toggle sits in the activity heading, straight after the
369 // heading word; the current view is marked, each word switches to its view.
370 const { lead, trail } = entry.toggle
371 return [
372 <Box key={entry.key} flexDirection="row">
373 <Text dimColor>──</Text>
374 <Text {...toneProps(entry.tone)}>{lead.slice(2)}</Text>
375 <Button key="overview" plain label={shaped.mode === 'overview' ? '● overview' : 'overview'} onPress={() => actions.setStepMode?.('overview')} />
376 <Text dimColor> · </Text>
377 <Button key="detail" plain label={shaped.mode === 'detail' ? '● detail' : 'detail'} onPress={() => actions.setStepMode?.('detail')} />
378 <Text {...toneProps(entry.tone)}>{trail.slice(0, -2)}</Text>
379 <Text dimColor>──</Text>
380 </Box>,
381 ]
382 }
383 const lines = wrapText(entry.text, width)
384 if (entry.segments && lines.length === 1) return [
385 <Text key={entry.key} wrap="truncate-end">
386 {entry.segments.map((part, index) => <Text key={`${entry.key}-part-${String(index)}`} {...toneProps(part.tone, part.pool)}>{part.text}</Text>)}
387 </Text>,
388 ]
389 if (entry.kind === 'section') return lines.map((line, index) => {
390 const leading = line.startsWith('──')
391 const trailing = line.endsWith('──')
392 const body = line.slice(leading ? 2 : 0, trailing ? -2 : undefined)
393 return <Text key={`${entry.key}-${String(index)}`} {...toneProps(entry.tone)}><Text dimColor>{leading ? '──' : ''}</Text>{body}<Text dimColor>{trailing ? '──' : ''}</Text></Text>
394 })
395 if (entry.kind === 'fold' && entry.opensDetail) return [
396 <Button key={entry.key} plain label={entry.text} onPress={() => actions.setStepMode?.('detail')} />,
397 ]
398 if (entry.kind === 'response' && entry.expandable) {
399 const [first, ...rest] = lines
400 if (entry.selected) return [
401 <Box key={entry.key} flexDirection="row">
402 <Button
403 key={`${entry.key}-close`}
404 plain
405 label="▶"
406 onPress={() => entry.turnIndex === undefined ? undefined : actions.expandStepTurn?.(entry.turnIndex)}
407 />
408 <Text inverse wrap="truncate-end">{(first ?? entry.text).replace(/^▶/, '')}</Text>
409 </Box>,
410 ...rest.map((line, index) => <Text key={`${entry.key}-continuation-${String(index)}`} inverse>{line}</Text>),
411 ]
412 return [
413 <Button
414 key={entry.key}
415 plain
416 label={first ?? entry.text}
417 onPress={() => entry.turnIndex === undefined ? undefined : actions.expandStepTurn?.(entry.turnIndex)}
418 />,
419 ...rest.map((line, index) => <Text key={`${entry.key}-continuation-${String(index)}`} dimColor>{line}</Text>),
420 ]
421 }
422 return lines.map((line, index) => <Text key={`${entry.key}-${String(index)}`} {...toneProps(entry.tone)}>{line}</Text>)
423 }
424 const rows: RenderElement[] = shaped.rows.flatMap(rowText)
425
426 const header: RenderElement[] = [
427 <Box key="h0" flexDirection="row">
428 <Text bold>Step </Text>
429 </Box>,
430 ]
431 if (!compact) header.push(nav)
432 if (model.error) header.push(plain('err', model.error, 'red'))
433 // The hint sits whole above the nav, as the usage page's note does.
434 const hintText = looseTask
435 ? `bullswarm workflow task show ${(looseTask as AssignmentRecord).id ?? a.id} · read-only Step view`
436 : shaped.runId
437 ? `bullswarm workflow action show ${shaped.runId} ${a.id}`
438 : 'bullswarm run task · read-only Step view'
439 const hint = wrapText(hintText, width)
440 const stepModeButton = (
441 <Button
442 key="step-mode"
443 hotkey="v"
444 label="v"
445 onPress={() => actions.toggleStep?.()}
446 />
447 )
448 const stepModeFooter = <Box key="step-mode-footer" flexDirection="row" gap={1}>{stepModeButton}<Text dimColor>{shaped.toggleHint.replace(/^v\s+/, '')}</Text></Box>
449 const footer: RenderElement[] = compact
450 ? [stepModeFooter, switcher]
451 : [...hint.map((l, k) => dim(`f${String(k)}`, l)), stepModeFooter, switcher]
452 return frame(header, rows, footer)
453 }
454
455 // The only path below is the ordinary run overview. The explicit guard
456 // keeps the standalone-task branch above type-safe when the host has no run.
457 if (!run) return { tree: <Text dimColor>No run selected.</Text>, scroll: null }
458
459 const age = ageOf(run.startedAt, model.nowMs)
460 const read = model.readAt ? ageOf(new Date(model.readAt).toISOString(), model.nowMs) : ''
461 const known = model.detail?.actions ?? []
462 const lines = model.overview ?? []
463
464 // One element per row, so the window's arithmetic holds: the frame lines
465 // are already wrapped to the pane's width by `--width`.
466 const rows: RenderElement[] = lines.map((line, i) => {
467 if (line.kind === 'blank') return <Text key={`b${String(i)}`}> </Text>
468 if (line.kind === 'header')
469 return (
470 <Text key={`h${String(i)}`} wrap="truncate-end">
471 <Text bold>{line.text}</Text>
472 <Text dimColor> {'─'.repeat(Math.max(0, Math.min(kit.columns, 120) - line.text.length - 1))}</Text>
473 </Text>
474 )
475 if (line.kind === 'section')
476 return (
477 <Text key={`s${String(i)}`} wrap="truncate-end" bold color={toneColor(line.tone)}>
478 {line.text}
479 </Text>
480 )
481 const step = line.kind === 'detail' ? null : actionOf(line.text, known)
482 if (step)
483 return (
484 <Box key={`a${String(i)}`} flexDirection="row">
485 <Button
486 key={`step-${step.id}-${String(i)}`}
487 plain
488 label={line.text}
489 onPress={() => actions.openAction(step.id)}
490 />
491 </Box>
492 )
493 return (
494 <Text
495 key={`l${String(i)}`}
496 wrap="truncate-end"
497 dimColor={line.kind === 'detail'}
498 color={line.kind === 'detail' ? undefined : toneColor(line.tone)}
499 >
500 {line.text}
501 </Text>
502 )
503 })
504 const poolsFooter: RenderElement[] = model.pools.length
505 ? [
506 <Text key="pools-rule" dimColor>
507 {rule}
508 </Text>,
509 <Box key="pools" flexDirection="row">
510 <Button key="pools-open" plain label="Pools ▸" onPress={actions.openPools} />
511 <Text dimColor wrap="truncate-end">
512 {' '}· used/elapsed · pace · ▏ marks elapsed · click for every window and the model per tier
513 </Text>
514 </Box>,
515 ...poolRows({ Box, Text }, model.pools, model.assignments, model.names),
516 ]
517 : []
518
519 const header: RenderElement[] = [
520 <Text key="h0" wrap="truncate-end">
521 <Text bold color="cyan">
522 {run.shortId}
523 </Text>
524 <Text>
525 {' '}
526 {run.status}
527 {age ? ` · ${age}` : ''} ·{' '}
528 </Text>
529 <Text color="green">
530 {run.actionsSucceeded}/{run.actionsTotal}
531 </Text>
532 <Text dimColor>{model.overview ? ` · ${read || '0m'} old` : ' · reading…'}</Text>
533 </Text>,
534 ]
535 if (!compact) header.push(nav)
536 if (model.error) header.push(plain('err', model.error, 'red'))
537 // The hint sits whole above the nav, as the usage page's note does.
538 const hint = wrapText(`click a step to open it · bullswarm workflow watch ${run.shortId} --next`, width)
539 const footer: RenderElement[] = compact
540 ? [switcher]
541 : [...poolsFooter, ...hint.map((l, k) => dim(`f${String(k)}`, l)), switcher]
542 return frame(header, rows, footer)
543}
544
545/** `1h41m`, `2d9h` until an ISO time, or ''. */
546const untilText = (iso: string | null, nowMs: number): string => {
547 if (!iso) return ''
548 const mins = Math.round((Date.parse(iso) - nowMs) / 60_000)
549 if (!Number.isFinite(mins) || mins < 0) return 'now'
550 if (mins < 60) return `${String(mins)}m`
551 const h = Math.floor(mins / 60)
552 if (h < 24) return `${String(h)}h${String(mins % 60).padStart(2, '0')}m`
553 return `${String(Math.floor(h / 24))}d${String(h % 24)}h`
554}
555
556/** `on track −5pp`, `slow +36pp` (quota to spare) or `hot −20pp` (ahead of the window), with a color. */
557const paceWord = (usedPct: number, elapsedPct: number | null): { text: string; color: string } => {
558 if (elapsedPct === null) return { text: '', color: 'gray' }
559 const pp = Math.round(elapsedPct - usedPct)
560 const signed = `${pp >= 0 ? '+' : '−'}${String(Math.abs(pp))}pp`
561 if (pp >= 15) return { text: `slow ${signed}`, color: METER_AMBER }
562 if (pp <= -15) return { text: `hot ${signed}`, color: METER_RED }
563 return { text: `on track ${signed}`, color: METER_GREEN }
564}
565
566/**
567 * The pools page: every meter window of every pool as a full width bar
568 * with the elapsed mark, its reset time and pace word, the credit meter
569 * when there is one; then every pool × tier rung with its model and
570 * reasoning. Read-only: the footer names the command that changes it.
571 */
572function poolsPageRows(
573 ui: PaneUi,
574 model: PaneModel,
575 width: number,
576 nameOf: (pool: string | null) => string,
577 actions: Pick<PaneActions, 'setRungsBy'>,
578): RenderElement[] {
579 const { Box, Text, Button } = ui
580 const rows: RenderElement[] = []
581 const barWidth = Math.max(10, width - 4 - 8)
582 for (const p of model.pools.filter(p => p.enabled)) {
583 rows.push(
584 <Text key={`x-${p.name}`} bold wrap="truncate-end">
585 {nameOf(p.name)}
586 {p.planType ? <Text dimColor> · {p.planType}</Text> : null}
587 </Text>,
588 )
589 if (!p.windows.length) rows.push(<Text key={`x-${p.name}-none`} dimColor> no meter reported</Text>)
590 for (const w of p.windows) {
591 const pace = paceWord(w.usedPct, w.elapsedPct)
592 rows.push(
593 <Box key={`x-${p.name}-${w.key}`} flexDirection="row">
594 <Text dimColor>{w.key.padEnd(3)} </Text>
595 {meterBar(Text, w.usedPct, w.elapsedPct, barWidth)}
596 <Text color={severityColor(w.usedPct)}> {`${w.usedPct.toFixed(1)}%`.padStart(6)}</Text>
597 </Box>,
598 <Text key={`x-${p.name}-${w.key}-r`} wrap="truncate-end">
599 <Text dimColor> {w.resetsAt ? `resets ${untilText(w.resetsAt, model.nowMs)}` : 'no reset time'}</Text>
600 {pace.text ? (
601 <Text color={pace.color}>
602 {' '}· {pace.text}
603 </Text>
604 ) : null}
605 </Text>,
606 )
607 }
608 if (p.credits)
609 rows.push(
610 <Text key={`x-${p.name}-credits`} dimColor wrap="truncate-end">
611 {' '}
612 {String(p.credits.used)} / {String(p.credits.limit)} {p.credits.unit}
613 </Text>,
614 )
615 rows.push(<Text key={`x-${p.name}-b`}> </Text>)
616 }
617
618 // The rungs: the model and reasoning each pool dispatches with per effort
619 // tier, and what the local record says about it, grouped by tier (the
620 // lane a task lands in) or by pool.
621 const tiers = ['high', 'medium', 'low']
622 const enabled = model.pools.filter(p => p.enabled)
623 const short = (m: string | null) => (m ? (m.split('/').pop() ?? m) : '—')
624 const recordOf = (r: BullswarmRung) =>
625 r.dispatches
626 ? `${String(r.dispatches)} run${r.dispatches === 1 ? '' : 's'}${r.okShare !== null ? ` · ${String(Math.round(r.okShare * 100))}% ok` : ''}${r.medianMinutes !== null ? ` · p50 ${String(Math.round(r.medianMinutes))}m` : ''}`
627 : 'no runs yet'
628 const rungRow = (key: string, _head: string, sub: string, r: BullswarmRung) => [
629 <Text key={key} wrap="truncate-end">
630 <Text>{' '}</Text>
631 <Text dimColor>{sub.padEnd(16).slice(0, 16)}</Text>
632 <Text color="cyan">{short(r.model)}</Text>
633 <Text>{r.reasoning ? ` · ${r.reasoning}` : ''}</Text>
634 </Text>,
635 <Text key={`${key}-r`} dimColor wrap="truncate-end">
636 {' '.repeat(18)}
637 {recordOf(r)}
638 </Text>,
639 ]
640 // What each lane carries, from the program kinds table: the effort a
641 // kind resolves to decides the tier a step dispatches on.
642 const laneBlurb: Record<string, string> = {
643 high: 'integration · architecture · adversarial-acceptance',
644 medium: 'implement · check (the ordinary writers)',
645 low: 'mechanical · io-read · digest',
646 }
647 const poolBlurb = (p: BullswarmPool) => {
648 const meter = p.usedPct !== null && p.elapsedPct !== null ? `${String(Math.round(p.usedPct))}% used of ${String(Math.round(p.elapsedPct))}% elapsed` : 'no meter'
649 const window = p.pacingWindow === 'monthly' ? 'monthly' : p.pacingWindow === 'weekly' ? 'weekly' : p.pacingWindow ?? ''
650 return [window ? `${window} window` : '', meter, p.quarantine ? 'quarantined' : '']
651 .filter(Boolean)
652 .join(' · ')
653 }
654 rows.push(
655 <Text key="x-rungs" wrap="truncate-end">
656 <Text bold>Rungs</Text>
657 <Text dimColor> · model · reasoning · record, per lane and pool</Text>
658 </Text>,
659 <Box key="x-rungs-tabs" flexDirection="row" gap={1} flexWrap="nowrap">
660 <Button key="rungs-tier" plain label={model.rungsBy === 'tier' ? '[● by lane]' : '[by lane]'} onPress={() => actions.setRungsBy('tier')} />
661 <Button key="rungs-pool" plain label={model.rungsBy === 'pool' ? '[● by provider]' : '[by provider]'} onPress={() => actions.setRungsBy('pool')} />
662 </Box>,
663 )
664 const group = (key: string, title: string, blurb: string) => [
665 <Text key={`${key}-gap`}> </Text>,
666 <Text key={`${key}-title`} wrap="truncate-end">
667 <Text bold>{title}</Text>
668 <Text dimColor> · {blurb}</Text>
669 </Text>,
670 ]
671 if (model.rungsBy === 'tier') {
672 for (const t of tiers) {
673 const mine = enabled
674 .map(p => ({ p, r: model.rungs.find(x => x.pool === p.name && x.tier === t) }))
675 .filter((x): x is { p: BullswarmPool; r: BullswarmRung } => !!x.r?.model)
676 if (!mine.length) continue
677 rows.push(...group(`x-lane-${t}`, t, laneBlurb[t] ?? ''))
678 for (const { p, r } of mine) rows.push(...rungRow(`x-rung-${t}-${p.name}`, '', nameOf(p.name), r))
679 }
680 } else {
681 for (const p of enabled) {
682 const mine = tiers
683 .map(t => ({ t, r: model.rungs.find(x => x.pool === p.name && x.tier === t) }))
684 .filter((x): x is { t: string; r: BullswarmRung } => !!x.r?.model)
685 if (!mine.length) continue
686 rows.push(...group(`x-pool-${p.name}`, nameOf(p.name), poolBlurb(p)))
687 for (const { t, r } of mine) rows.push(...rungRow(`x-rung-${p.name}-${t}`, '', t, r))
688 }
689 }
690 if (!model.rungs.length) rows.push(<Text key="x-rungs-none" dimColor> reading rungs…</Text>)
691 return rows
692}
693hooks/runs.ts 352 lines1import type {
2 BullswarmAction,
3 BullswarmAssignment,
4 BullswarmAttempt,
5 BullswarmRun,
6 BullswarmRunDetail,
7 BullswarmStep,
8 BullswarmStepAttempt,
9} from '../types'
10
11type Raw = Record<string, unknown>
12
13/** The rich, serializable Step model appended by `action show --json`. */
14export type ParsedBullswarmStep = BullswarmStep & {
15 /** The JSON `step` object; kept separate from the legacy compact projection. */
16 page: Raw | null
17 /** Action-scoped durable events, retained for callers that need raw context. */
18 events: readonly Raw[]
19}
20
21export type BullswarmAssignmentRecord = BullswarmAssignment & {
22 id?: string
23 startedAt?: string
24 project?: string
25 projectName?: string
26 cwd?: string
27 taskFile?: string
28 task?: string
29 description?: string
30}
31
32const num = (v: unknown): number | null =>
33 typeof v === 'number' && Number.isFinite(v) ? v : null
34const str = (v: unknown): string | null => (typeof v === 'string' ? v : null)
35
36const optionalStrings = (
37 raw: Raw,
38 fallback: BullswarmAssignmentRecord | null,
39 keys: readonly ('id' | 'startedAt' | 'project' | 'projectName' | 'cwd' | 'taskFile' | 'task' | 'description')[],
40): Partial<BullswarmAssignmentRecord> => {
41 const fields: Partial<BullswarmAssignmentRecord> = {}
42 for (const key of keys) {
43 const value = str(raw[key]) ?? str(fallback?.[key])
44 if (value !== null) fields[key] = value
45 }
46 return fields
47}
48
49/** Reads one raw ledger record, preserving optional fields newer records may carry. */
50export function parseAssignmentRecord(
51 value: unknown,
52 fallback: BullswarmAssignmentRecord | null = null,
53): BullswarmAssignmentRecord | null {
54 if (!value || typeof value !== 'object') return fallback
55 const raw = value as Raw
56 const pool = str(raw.pool) ?? fallback?.pool ?? null
57 if (!pool) return fallback
58 return {
59 pool,
60 poolLabel: str(raw.poolLabel) ?? fallback?.poolLabel ?? null,
61 model: str(raw.model) ?? fallback?.model ?? null,
62 lane: str(raw.lane) ?? fallback?.lane ?? '',
63 source: str(raw.source) ?? fallback?.source ?? '',
64 runId: str(raw.runId) ?? fallback?.runId ?? null,
65 actionId: str(raw.actionId) ?? fallback?.actionId ?? null,
66 elapsedMinutes: num(raw.elapsedMinutes) ?? fallback?.elapsedMinutes ?? null,
67 expectedMinutes: num(raw.expectedMinutes) ?? fallback?.expectedMinutes ?? null,
68 ...optionalStrings(raw, fallback, ['id', 'startedAt', 'project', 'projectName', 'cwd', 'taskFile', 'task', 'description']),
69 }
70}
71
72/** Reads `bullswarm workflow runs --json` (ongoing runs) into the run model. */
73export function parseRuns(stdout: string): BullswarmRun[] {
74 const doc = JSON.parse(stdout) as { runs?: Raw[] }
75 const raw = Array.isArray(doc.runs) ? doc.runs : []
76 return raw
77 .filter(r => typeof r.runId === 'string' && r.legacy !== true)
78 .map(r => ({
79 runId: r.runId as string,
80 shortId: str(r.shortId) ?? (r.runId as string).slice(-6),
81 goal: str(r.goal) ?? '',
82 status: str(r.status) ?? 'unknown',
83 startedAt: str(r.startedAt),
84 actionsSucceeded: num(r.actionsSucceeded) ?? 0,
85 actionsTotal: num(r.actionsTotal) ?? 0,
86 waitingFor: Array.isArray(r.waitingFor)
87 ? (r.waitingFor as Raw[])
88 .filter(n => typeof n?.id === 'string')
89 .map(n => ({ id: n.id as string, type: str(n.type) ?? 'gate', note: str(n.note) }))
90 : [],
91 }))
92}
93
94/** `waiting at gate approve`, or the run's own status word. */
95export function runStatusText(run: BullswarmRun): string {
96 if (!run.waitingFor?.length) return run.status
97 return `waiting at ${run.waitingFor.map(n => `${n.type} ${n.id}`).join(', ')}`
98}
99
100/** The commands that move a parked run's gates and loops. */
101export function continueCommands(run: BullswarmRun): string[] {
102 return (run.waitingFor ?? []).map(n =>
103 `bullswarm workflow continue ${run.shortId} ${n.id}${n.type === 'loop' ? ' --rounds <n>' : ''}`)
104}
105
106/** Reads `bullswarm assignments --json` (the in-flight ledger). */
107export function parseAssignments(stdout: string): BullswarmAssignmentRecord[] {
108 const doc = JSON.parse(stdout) as unknown
109 const raw = Array.isArray(doc) ? (doc as Raw[]) : []
110 return raw
111 .map(a => parseAssignmentRecord(a))
112 .filter((a): a is BullswarmAssignmentRecord => a !== null)
113}
114
115/** `3m/9m`, `3m`, or ''. */
116export function timingOf(a: BullswarmAssignment): string {
117 if (a.elapsedMinutes === null) return ''
118 const e = `${Math.round(a.elapsedMinutes)}m`
119 return a.expectedMinutes !== null ? `${e}/${Math.round(a.expectedMinutes)}m` : e
120}
121
122/** Minutes since an ISO timestamp, as `42m` or `2h05m`. */
123export function ageOf(iso: string | null, nowMs: number): string {
124 if (!iso) return ''
125 const mins = Math.max(0, Math.round((nowMs - Date.parse(iso)) / 60_000))
126 if (!Number.isFinite(mins)) return ''
127 return mins < 60 ? `${mins}m` : `${Math.floor(mins / 60)}h${String(mins % 60).padStart(2, '0')}m`
128}
129
130/** The steps of one run in flight right now. */
131export function stepsOf(run: BullswarmRun, assignments: readonly BullswarmAssignment[]) {
132 return assignments.filter(a => a.runId === run.runId)
133}
134
135/** Standalone `bullswarm run` dispatches, not part of any listed run. */
136export function looseOf(
137 runs: readonly BullswarmRun[],
138 assignments: readonly BullswarmAssignment[],
139): BullswarmAssignment[] {
140 const known = new Set(runs.map(r => r.runId))
141 return assignments.filter(a => !a.runId || !known.has(a.runId))
142}
143
144/** Standalone task assignments in nav order; workflow digits never depend on these. */
145export function standaloneTasks(assignments: readonly BullswarmAssignment[]): BullswarmAssignmentRecord[] {
146 return assignments.filter((a): a is BullswarmAssignmentRecord => a.source === 'run' && typeof (a as BullswarmAssignmentRecord).id === 'string')
147}
148
149export type PaneChoice =
150 | { kind: 'workflow'; shortId: string }
151 | { kind: 'task'; taskId: string }
152 | null
153
154/**
155 * Resolve the pane without letting an unrelated live task override a workflow.
156 * An explicit task wins, then an explicit workflow, then the first workflow;
157 * a task is promoted only when there is no workflow to show.
158 */
159export function paneChoice(
160 runs: readonly BullswarmRun[],
161 assignments: readonly BullswarmAssignment[],
162 selectedShortId: string | null,
163 selectedTaskId: string | null,
164): PaneChoice {
165 const tasks = standaloneTasks(assignments)
166 const task = selectedTaskId ? tasks.find(a => a.id === selectedTaskId) : null
167 if (task?.id) return { kind: 'task', taskId: task.id }
168 const run = (selectedShortId ? runs.find(r => r.shortId === selectedShortId) : null) ?? runs[0] ?? null
169 if (run) return { kind: 'workflow', shortId: run.shortId }
170 return tasks[0]?.id ? { kind: 'task', taskId: tasks[0].id } : null
171}
172
173/** One plain-text line per run, for `/bullswarm runs` and the context block. */
174export function runLine(
175 run: BullswarmRun,
176 assignments: readonly BullswarmAssignment[],
177 nowMs: number,
178): string {
179 const steps = stepsOf(run, assignments)
180 .map(a => `${a.actionId ?? '?'} on ${a.pool}${timingOf(a) ? ` ${timingOf(a)}` : ''}`)
181 .join(', ')
182 const progress = `${run.actionsSucceeded}/${run.actionsTotal} done`
183 const age = ageOf(run.startedAt, nowMs)
184 const next = continueCommands(run)
185 return `${run.shortId} (${runStatusText(run)}${age ? `, ${age}` : ''}): ${progress}${steps ? `; running ${steps}` : ''}${next.length ? `; continue: ${next.join(' or ')}` : ''} — ${run.goal.slice(0, 80)}${run.goal.length > 80 ? '…' : ''}`
186}
187
188const activityOf = (v: unknown): string | null => {
189 if (typeof v === 'string') return v
190 if (v && typeof v === 'object') {
191 const o = v as Raw
192 return str(o.summary) ?? str(o.kind) ?? str(o.status) ?? null
193 }
194 return null
195}
196
197/** Reads `bullswarm workflow runs show <id> --json` into the action list. */
198export function parseDetail(stdout: string, readAt: number): BullswarmRunDetail {
199 const doc = JSON.parse(stdout) as Raw
200 const state = (doc.state ?? {}) as Raw
201 const rawAttempts = Array.isArray(state.attempts) ? (state.attempts as Raw[]) : []
202 const attempts: BullswarmAttempt[] = rawAttempts
203 .filter(a => typeof a.actionId === 'string')
204 .map(a => ({
205 actionId: a.actionId as string,
206 ordinal: num(a.ordinal) ?? 0,
207 status: str(a.status) ?? 'unknown',
208 pool: str(a.pool),
209 model: str(a.model),
210 startedAt: str(a.startedAt),
211 finishedAt: str(a.finishedAt),
212 why: str(a.why),
213 wallSec: num(a.wallSec),
214 lastActivity: activityOf(a.lastAgentEvent),
215 }))
216 const rawActions = Array.isArray(state.actions) ? (state.actions as Raw[]) : []
217 const actions: BullswarmAction[] = rawActions
218 .filter(a => typeof a.id === 'string')
219 .map(a => {
220 const mine = attempts.filter(t => t.actionId === a.id)
221 const latest = mine.sort((x, y) => y.ordinal - x.ordinal)[0] ?? null
222 const failure = a.lastFailure
223 return {
224 id: a.id as string,
225 status: str(a.status) ?? 'unknown',
226 attempts: num(a.attempts) ?? mine.length,
227 startedAt: str(a.startedAt),
228 finishedAt: str(a.finishedAt),
229 outputFile: str(a.outputFile),
230 lastFailure:
231 typeof failure === 'string'
232 ? failure
233 : failure && typeof failure === 'object'
234 ? (str((failure as Raw).why) ?? str((failure as Raw).kind))
235 : null,
236 latest,
237 }
238 })
239 return {
240 runId: str(doc.runId) ?? '',
241 shortId: str(doc.shortId) ?? '',
242 ongoing: doc.ongoing === true,
243 actions,
244 readAt,
245 }
246}
247
248/** `6m12s` for a finished attempt, `3m` so far for a running one, or ''. */
249export function durationOf(a: BullswarmAction, nowMs: number): string {
250 const wall = a.latest?.wallSec
251 if (wall !== null && wall !== undefined) {
252 const m = Math.floor(wall / 60)
253 const sec = Math.round(wall % 60)
254 return m ? `${m}m${String(sec).padStart(2, '0')}s` : `${sec}s`
255 }
256 if (a.startedAt && !a.finishedAt) return `${ageOf(a.startedAt, nowMs)} so far`
257 return ''
258}
259
260/** A glyph and color per action status. */
261export function glyphOf(status: string): { glyph: string; color: string } {
262 switch (status) {
263 case 'succeeded':
264 return { glyph: '✓', color: 'green' }
265 case 'running':
266 return { glyph: '▶', color: 'cyan' }
267 case 'failed':
268 return { glyph: '✗', color: 'red' }
269 case 'cancelled':
270 case 'skipped':
271 return { glyph: '–', color: 'yellow' }
272 default:
273 return { glyph: '·', color: 'gray' }
274 }
275}
276
277/** `tokens read=… output=… · cost≈$…`, as the TUI's compact usage line. */
278function usageText(v: unknown): string | null {
279 if (!v || typeof v !== 'object') return null
280 const u = v as Raw
281 const t = (u.tokens ?? {}) as Raw
282 const cost = (u.cost ?? {}) as Raw
283 const parts = [
284 `tokens read=${String(num(t.standardRead) ?? '?')} output=${String(num(t.output) ?? '?')}`,
285 num(cost.estimatedUsd) !== null ? `cost≈$${String(num(cost.estimatedUsd))}` : '',
286 ]
287 return parts.filter(Boolean).join(' · ')
288}
289
290const eventText = (v: unknown): string | null => {
291 if (!v || typeof v !== 'object') return null
292 const e = v as Raw
293 const bits = [str(e.kind) ?? str(e.providerType), str(e.summary), str(e.status)].filter(
294 (x): x is string => !!x,
295 )
296 return bits.length ? bits.join(' · ') : null
297}
298
299/** Reads `bullswarm workflow action show <run> <step> --json` into one step. */
300export function parseStep(stdout: string, readAt: number): ParsedBullswarmStep {
301 const doc = JSON.parse(stdout) as Raw
302 const rec = (doc.actionRecord ?? {}) as Raw
303 const rawAttempts = Array.isArray(doc.attempts) ? (doc.attempts as Raw[]) : []
304 const last = [...rawAttempts].sort((x, y) => (num(y.ordinal) ?? 0) - (num(x.ordinal) ?? 0))[0]
305 const attempt: BullswarmStepAttempt | null = last
306 ? {
307 ordinal: num(last.ordinal) ?? 1,
308 status: str(last.status) ?? 'unknown',
309 pool: str(last.pool),
310 model: str(last.model),
311 reasoning: str(((last.reasoning ?? {}) as Raw).applied),
312 routeReason: str(((last.routing ?? {}) as Raw).reason),
313 startedAt: str(last.startedAt),
314 finishedAt: str(last.finishedAt),
315 lastActivityAt: str(last.lastActivityAt),
316 outputBytes: num(last.outputBytesObserved),
317 taskFile: str(last.taskFile),
318 outputFile: str(last.outputFile),
319 why: str(last.why),
320 wallSec: num(last.wallSec),
321 usage: usageText(last.usage),
322 lastEvent: eventText(last.lastAgentEvent),
323 }
324 : null
325 const failure = rec.lastFailure
326 const page = doc.step && typeof doc.step === 'object' && !Array.isArray(doc.step)
327 ? (doc.step as Raw)
328 : null
329 const events = Array.isArray(doc.events)
330 ? (doc.events as unknown[]).filter((event): event is Raw => Boolean(event && typeof event === 'object' && !Array.isArray(event)))
331 : []
332 return {
333 id: str(rec.id) ?? '',
334 purpose: str(rec.purpose) ?? '',
335 kind: str(rec.kind),
336 lane: str(rec.lane),
337 effort: str(rec.effort),
338 status: str(rec.status) ?? 'unknown',
339 outputFile: str(rec.outputFile) ?? attempt?.outputFile ?? null,
340 lastFailure:
341 typeof failure === 'string'
342 ? failure
343 : failure && typeof failure === 'object'
344 ? (str((failure as Raw).why) ?? str((failure as Raw).kind))
345 : null,
346 attempt,
347 readAt,
348 page,
349 events,
350 }
351}
352hooks/strip.tsx 149 lines1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import type { ElementTable, RenderElement } from 'claude-code'
5
6import type { BullswarmAssignment, BullswarmPool, BullswarmRun } from '../types'
7import { poolRows } from './pool-rows'
8import { looseOf, runStatusText, stepsOf, timingOf } from './runs'
9
10export type StripUi = Pick<
11 ElementTable<'terminal' | 'desktop'>,
12 'Box' | 'Text' | 'Button'
13>
14
15export type StripModel = {
16 pools: readonly BullswarmPool[]
17 runs: readonly BullswarmRun[]
18 assignments: readonly BullswarmAssignment[]
19 autoRoute: boolean
20 routedCount: number
21 lastError: string | null
22 paneOpen: boolean
23 selectedShortId: string | null
24 /** Pool name → what the strip shows for it. */
25 names: ReadonlyMap<string, string>
26 /** `full` draws the pool rows too; `runs` only the run rows. */
27 level: 'runs' | 'full'
28}
29
30export type StripActions = {
31 /** Opens (or closes) the run pane on the selected run. */
32 pane: () => void
33 /** Selects one run and opens the pane on it. */
34 open: (shortId: string) => void
35}
36
37/**
38 * The strip in the AbovePrompt band, kept to one idea per row: a header
39 * line with the pane button, one row per pool (bar, used/elapsed, pace,
40 * what is running there), one row per ongoing run (progress, the step in
41 * flight, the goal), one per standalone dispatch.
42 */
43export function strip(
44 kit: { ui: StripUi; columns: number; maxRows: number },
45 model: StripModel,
46 actions: StripActions,
47): RenderElement {
48 const { Box, Text, Button } = kit.ui
49 const budget = Math.max(1, kit.maxRows - 1)
50 const pools = model.level === 'full' ? model.pools.filter(p => p.enabled).slice(0, budget) : []
51 const loose = looseOf(model.runs, model.assignments)
52 const runRows = Math.max(0, budget - pools.length)
53 const runs = model.runs.slice(0, runRows)
54 const looseShown = loose.slice(0, Math.max(0, runRows - runs.length))
55 const nameOf = (pool: string) => model.names.get(pool) ?? pool
56
57 const summary = [
58 model.runs.length ? `${model.runs.length} run${model.runs.length === 1 ? '' : 's'}` : 'no runs',
59 model.assignments.length ? `${model.assignments.length} in flight` : '',
60 model.routedCount ? `${model.routedCount} routed` : '',
61 model.autoRoute ? '' : 'route off',
62 ]
63 .filter(Boolean)
64 .join(' · ')
65
66 return (
67 <Box flexDirection="column">
68 <Box flexDirection="row" flexWrap="nowrap" gap={1} marginRight={4}>
69 <Box flexShrink={1} overflow="hidden">
70 <Text wrap="truncate-end">
71 <Text bold color="magenta">
72 bullswarm
73 </Text>
74 <Text dimColor> {summary}{model.level === 'full' ? ' · used/elapsed · pace' : ''}</Text>
75 </Text>
76 </Box>
77 <Box flexShrink={0} flexDirection="row" gap={1}>
78 <Button
79 key="pane"
80 hotkey="w"
81 label={model.paneOpen ? '[w] close' : '[w] runs'}
82 onPress={actions.pane}
83 />
84 </Box>
85 </Box>
86 {model.lastError ? (
87 <Text color="red" wrap="truncate-end">
88 {model.lastError}
89 </Text>
90 ) : null}
91 {poolRows({ Box, Text }, pools, model.assignments, model.names)}
92 {runs.map((run, i) => {
93 const steps = stepsOf(run, model.assignments)
94 const done = `${run.actionsSucceeded}/${run.actionsTotal}`
95 // One step in flight is named; the rest are counted, so the goal
96 // that says which run this is always stays on the row.
97 const named = steps
98 .slice(0, 1)
99 .map(a => `${a.actionId ?? '?'}@${nameOf(a.pool)}${timingOf(a) ? ` ${timingOf(a)}` : ''}`)
100 .join(', ')
101 const running = steps.length > 1 ? `${named}, and ${String(steps.length - 1)} more` : named
102 const finished = run.status !== 'running'
103 // A parked v3 run waits for the caller at a gate or loop.
104 const waiting = (run.waitingFor ?? []).length > 0
105 const selected = model.selectedShortId === run.shortId && model.paneOpen
106
107 return (
108 <Box key={run.runId} flexDirection="row">
109 <Button
110 key={`open-${run.shortId}`}
111 plain
112 hotkey={i < 9 ? String(i + 1) : undefined}
113 label={`${waiting ? '⧖' : finished ? '◼' : '▶'} ${run.shortId}`}
114 onPress={() => actions.open(run.shortId)}
115 />
116 <Text wrap="truncate-end">
117 <Text color="magenta">{selected ? ' ●' : ''}</Text>
118 <Text color="green">
119 {' '}
120 {done}
121 </Text>
122 {finished ? <Text color="yellow"> {runStatusText(run)}</Text> : null}
123 <Text color="cyan">{running ? ` ${running}` : ''}</Text>
124 <Text dimColor> {run.goal}</Text>
125 </Text>
126 </Box>
127 )
128 })}
129 {looseShown.map((a, i) => (
130 <Box key={`loose-${String(i)}`} flexDirection="row">
131 <Text wrap="truncate-end">
132 <Text color="cyan" bold>
133 ▶ run
134 </Text>
135 <Text>
136 {' '}
137 {a.lane}@{nameOf(a.pool)}
138 </Text>
139 <Text dimColor>
140 {timingOf(a) ? ` ${timingOf(a)}` : ''}
141 {a.source ? ` ${a.source}` : ''}
142 </Text>
143 </Text>
144 </Box>
145 ))}
146 </Box>
147 )
148}
149hooks/step.ts 442 lines1import type { BullswarmStep } from '../types'
2
3type Raw = Record<string, unknown>
4
5export type StepMode = 'overview' | 'detail'
6export type StepTone = 'normal' | 'dim' | 'good' | 'bad' | 'running' | 'strong' | 'pool'
7
8export type StepPaneSegment = { text: string; tone: StepTone; pool?: string }
9
10/** One width-aware row for the Claude pane. JSX belongs in pane.tsx. */
11export type StepPaneRow = {
12 key: string
13 kind: 'section' | 'text' | 'response' | 'summary' | 'atomic' | 'artifact' | 'fold'
14 text: string
15 tone: StepTone
16 segments?: readonly StepPaneSegment[]
17 turnIndex?: number
18 selected?: boolean
19 expandable?: boolean
20 opensDetail?: boolean
21 /** A heading that carries the `overview · detail` toggle: `text` is `${lead}overview · detail${trail}`. */
22 toggle?: { lead: string; trail: string }
23}
24
25export type StepPaneBlock = {
26 key: 'header' | 'task' | 'activity' | 'result' | 'cost'
27 title: string
28 rows: readonly StepPaneRow[]
29}
30
31export type StepPaneModel = {
32 mode: StepMode
33 actionId: string
34 runId: string | null
35 blocks: readonly StepPaneBlock[]
36 rows: readonly StepPaneRow[]
37 toggleLabel: 'detail' | 'overview'
38 toggleHint: string
39}
40
41export type StepPaneOptions = {
42 mode?: StepMode
43 expandedTurn?: number | null
44 promptPreview?: readonly string[]
45 outputTail?: string | null
46 width?: number
47 nowMs?: number
48}
49
50const record = (value: unknown): Raw | null =>
51 value && typeof value === 'object' && !Array.isArray(value) ? value as Raw : null
52const list = (value: unknown): readonly unknown[] => Array.isArray(value) ? value : []
53const valueText = (value: unknown, fallback = '—'): string => {
54 if (value === null || value === undefined) return fallback
55 if (typeof value === 'object') {
56 try { return JSON.stringify(value) }
57 catch { return String(value) }
58 }
59 const result = String(value).trim()
60 return result || fallback
61}
62const oneLine = (value: unknown, fallback = '—'): string => valueText(value, fallback).replace(/\s+/g, ' ')
63const nullable = (value: unknown): string | null => {
64 const result = oneLine(value, '')
65 return result || null
66}
67const numberOf = (value: unknown): number | null => {
68 if (value === null || value === undefined || value === '') return null
69 const parsed = Number(value)
70 return Number.isFinite(parsed) ? parsed : null
71}
72const row = (
73 key: string,
74 kind: StepPaneRow['kind'],
75 text: string,
76 tone: StepTone = 'normal',
77 extra: Partial<Omit<StepPaneRow, 'key' | 'kind' | 'text' | 'tone'>> = {},
78): StepPaneRow => ({ key, kind, text, tone, ...extra })
79const segment = (text: string, tone: StepTone = 'normal', pool?: string): StepPaneSegment => ({ text, tone, ...(pool ? { pool } : {}) })
80const clip = (value: string, cells: number): string => value.length > cells ? `${value.slice(0, Math.max(0, cells - 1)).trimEnd()}…` : value
81
82/** The rich model attached by `workflow action show --json`. */
83export function stepPageOf(value: unknown): Raw {
84 const root = record(value)
85 return record(root?.page) ?? record(root?.step) ?? root ?? {}
86}
87
88const presentationOf = (page: Raw): Raw => record(page.presentation) ?? {}
89
90/** Resolve the selected attempt in compact Step JSON schema v2. */
91function selectedAttemptOf(page: Raw): Raw {
92 const direct = record(page.selectedAttempt)
93 if (direct && !nullable(direct.sameAs)) return direct
94 const attempts = list(page.attempts).map(record).filter((attempt): attempt is Raw => attempt !== null)
95 const ordinal = numberOf(page.selectedAttemptOrdinal)
96 return attempts.find(attempt => numberOf(attempt.ordinal) === ordinal) ?? attempts.at(-1) ?? {}
97}
98
99function activityOf(page: Raw): Raw {
100 return record(page.activity) ?? record(selectedAttemptOf(page).activity) ?? {}
101}
102const statusTone = (status: unknown): StepTone => {
103 const value = String(status ?? '').toLowerCase()
104 if (['succeeded', 'success', 'completed', 'complete', 'verified'].includes(value)) return 'good'
105 if (['failed', 'failure', 'error', 'cancelled', 'canceled', 'interrupted', 'blocked'].includes(value)) return 'bad'
106 if (['running', 'started', 'in_progress', 'in-progress', 'queued'].includes(value)) return 'running'
107 return 'dim'
108}
109const statusGlyph = (state: unknown): string => state === 'ok' ? '✓' : state === 'fail' ? '✗' : '▶'
110
111function headerRows(page: Raw, fallback: BullswarmStep | null, options: StepPaneOptions): StepPaneRow[] {
112 const header = record(presentationOf(page).header) ?? {}
113 const identity = record(page.identity) ?? {}
114 const attempt = selectedAttemptOf(page)
115 const fallbackAttempt = record(fallback?.attempt) ?? {}
116 const actionId = oneLine(header.actionId ?? identity.actionId ?? fallback?.id, 'step')
117 const shortId = nullable(header.shortId ?? identity.shortId)
118 const status = oneLine(header.status ?? identity.status ?? attempt.status ?? fallbackAttempt.status ?? fallback?.status, 'unknown').toLowerCase()
119 const tone = statusTone(status)
120 const phone = (options.width ?? 120) < 100
121 const fullVerdict = nullable(header.verdictText)
122 const verdict = phone && fullVerdict?.startsWith('verified by the workflow') ? 'verified'
123 : phone && fullVerdict?.startsWith('not verified') ? 'not verified'
124 : fullVerdict
125 const attemptText = phone ? null : nullable(header.attemptText)
126 const state = header.state ?? (tone === 'good' ? 'ok' : tone === 'bad' ? 'fail' : 'running')
127 const identityParts: StepPaneSegment[] = [
128 segment(`${statusGlyph(state)} `, tone), segment(actionId, 'strong'),
129 ...(shortId ? [segment(' · '), segment(shortId, 'strong')] : []),
130 segment(' · '), segment(status, tone),
131 ...(verdict ? [segment(' · '), segment(verdict, verdict.startsWith('not ') ? 'bad' : 'good')] : []),
132 ...(attemptText ? [segment(' · '), segment(attemptText, 'dim')] : []),
133 ]
134 const pool = nullable(header.pool ?? attempt.pool ?? fallbackAttempt.pool)
135 const model = nullable(header.model ?? attempt.model ?? fallbackAttempt.model)
136 const effort = nullable(header.effort ?? fallback?.effort)
137 const reasoning = nullable(header.reasoning)
138 const clock = nullable(header.clockText)
139 const clockRange = header.startedClock && header.finishedClock
140 ? `${String(header.startedClock)} → ${String(header.finishedClock)} HKT`
141 : nullable(header.startedClock) ? `since ${String(header.startedClock)} HKT` : null
142 const metaParts: StepPaneSegment[] = [
143 ...(pool ? [segment(pool, 'pool', pool)] : []),
144 ...(model ? [segment(`${pool ? ' · ' : ''}${model}`)] : []),
145 ...(effort ? [segment(` · ${effort}${phone ? '' : ' effort'}`)] : []),
146 ...(!phone && reasoning ? [segment(` · reasoning ${reasoning}`)] : []),
147 ...((clock || clockRange) ? [segment(` · ${[clock, clockRange?.replace(' → ', '→')].filter(Boolean).join(' · ')}`, 'dim')] : []),
148 ]
149 return [
150 row('identity', 'text', identityParts.map(part => part.text).join(''), tone, { segments: identityParts }),
151 ...(nullable(header.purpose ?? identity.purpose ?? fallback?.purpose)
152 ? [row('purpose', 'text', oneLine(header.purpose ?? identity.purpose ?? fallback?.purpose))]
153 : []),
154 row('meta', 'text', metaParts.map(part => part.text).join('') || '—', 'normal', { segments: metaParts }),
155 ...(nullable(header.route) ? [row('route', 'text', `route ${oneLine(header.route)}`, 'dim')] : []),
156 ]
157}
158
159function taskRows(page: Raw, fallback: BullswarmStep | null, options: StepPaneOptions): StepPaneRow[] {
160 const task = record(presentationOf(page).task) ?? record(page.taskBlock) ?? {}
161 const promptLines = list(task.promptLines).length ? list(task.promptLines) : options.promptPreview ?? []
162 const promptWidth = Math.max(20, options.width ?? 120)
163 const rows = promptLines.slice(0, 3).map((line, index) => row(`prompt-${index}`, 'text', clip(oneLine(line, ''), promptWidth)))
164 const facts: Array<[string, unknown]> = [['owns', task.owns], ['after', task.after], ['affects', task.affects]]
165 for (const [label, value] of facts) {
166 const values = list(value).map(entry => oneLine(entry, '')).filter(Boolean)
167 if (values.length) rows.push(row(label, 'text', `${label} ${values.join(' · ')}`, 'dim'))
168 }
169 const bytes = record(task.bytes)
170 if (bytes) {
171 const kb = (value: unknown) => numberOf(value) === null ? '—' : `${(numberOf(value)! / 1000).toFixed(1)} KB`
172 rows.push(row('task-open', 'text', `Enter on task: full text ${kb(bytes.authorPrompt)} · kernel wrapper ${kb(bytes.kernel)}`, 'dim'))
173 }
174 if (!rows.length) rows.push(row('task-unavailable', 'text', fallback?.purpose ?? 'task unavailable', 'dim'))
175 return rows
176}
177
178type ToolRow = { clock: string | null; text: string; command: boolean; duration: string | null; error: boolean; inFlight: boolean }
179
180function clockOf(value: unknown, seconds = false): string | null {
181 const date = new Date(String(value ?? ''))
182 if (!Number.isFinite(date.getTime())) return null
183 return new Intl.DateTimeFormat('en-GB', {
184 hour: '2-digit', minute: '2-digit', ...(seconds ? { second: '2-digit' } : {}), hour12: false,
185 }).format(date)
186}
187
188function rawToolRows(turn: Raw): ToolRow[] {
189 const events = list(turn.atomicEvents).map(record).filter((event): event is Raw => event !== null)
190 const paired = new Set<number>()
191 const rows: ToolRow[] = []
192 for (let index = 0; index < events.length; index += 1) {
193 if (paired.has(index)) continue
194 const event = events[index]!
195 const kind = String(event.kind ?? '').toLowerCase()
196 if (kind === 'response' || kind === 'usage' || kind === 'envelope' || kind === 'agent_result') continue
197 const next = events[index + 1]
198 const samePair = next
199 && String(next.kind ?? '') === String(event.kind ?? '')
200 && oneLine(next.summary, '') === oneLine(event.summary, '')
201 && String(event.status ?? '') === 'running'
202 && ['completed', 'failed', 'error'].includes(String(next.status ?? ''))
203 if (samePair) paired.add(index + 1)
204 const command = /command|shell|bash/.test(kind)
205 const durationMs = numberOf(next?.durationMs ?? event.durationMs)
206 rows.push({
207 clock: clockOf(event.at, true), text: oneLine(event.summary ?? event.tool ?? event.kind, 'tool'), command,
208 duration: durationMs !== null && durationMs >= 1000 ? `${Math.round(durationMs / 1000)}s` : null,
209 error: ['failed', 'error'].includes(String(next?.status ?? event.status ?? '')),
210 inFlight: String(event.status ?? '') === 'running' && !samePair,
211 })
212 }
213 return rows
214}
215
216function toolsFor(page: Raw, shownTurn: Raw): readonly ToolRow[] {
217 const projected = list(shownTurn.toolRows).map(record).filter((entry): entry is Raw => entry !== null)
218 if (projected.length) return projected.map(entry => ({
219 clock: nullable(entry.clock), text: oneLine(entry.text, 'tool'), command: entry.command === true,
220 duration: nullable(entry.durationText), error: entry.error === true,
221 inFlight: entry.inFlight === true,
222 }))
223 const rawActivity = activityOf(page)
224 const rawTurn = list(rawActivity.turns).map(record).find(turn => numberOf(turn?.index) === numberOf(shownTurn.index))
225 if (!rawTurn) return []
226 const atomic = list(rawTurn.atomicEvents)
227 if (atomic.length) return rawToolRows(rawTurn)
228 const events = list(rawActivity.events)
229 const indices = list(rawTurn.eventIndices).map(numberOf).filter((index): index is number => index !== null)
230 return rawToolRows({ ...rawTurn, atomicEvents: indices.map(index => events[index]).filter(Boolean) })
231}
232
233function totalsOf(turns: readonly Raw[]): string {
234 const totals = { commands: 0, filesRead: 0, searches: 0, edits: 0, otherTools: 0, errors: 0 }
235 for (const turn of turns) {
236 const summary = record(turn.summary) ?? {}
237 for (const key of Object.keys(totals) as Array<keyof typeof totals>) totals[key] += numberOf(summary[key]) ?? 0
238 }
239 return [
240 totals.commands ? `${totals.commands} command${totals.commands === 1 ? '' : 's'}` : null,
241 totals.filesRead ? `${totals.filesRead} file${totals.filesRead === 1 ? '' : 's'} read` : null,
242 totals.searches ? `${totals.searches} search${totals.searches === 1 ? '' : 'es'}` : null,
243 totals.edits ? `${totals.edits} edit${totals.edits === 1 ? '' : 's'}` : null,
244 totals.otherTools ? `${totals.otherTools} other tool${totals.otherTools === 1 ? '' : 's'}` : null,
245 totals.errors ? `${totals.errors} error${totals.errors === 1 ? '' : 's'}` : null,
246 ].filter(Boolean).join(' · ')
247}
248
249function elapsedText(startedAt: unknown, nowMs: unknown): string | null {
250 const started = Date.parse(String(startedAt ?? ''))
251 const now = numberOf(nowMs)
252 if (!Number.isFinite(started) || now === null) return null
253 const seconds = Math.max(0, Math.floor((now - started) / 1000))
254 if (seconds < 60) return `${seconds}s`
255 const minutes = Math.floor(seconds / 60)
256 return `${minutes}m${String(seconds % 60).padStart(2, '0')}s`
257}
258
259function activityRows(page: Raw, options: StepPaneOptions): StepPaneRow[] {
260 const activity = record(presentationOf(page).activity) ?? {}
261 const turns = list(activity.turns).map(record).filter((turn): turn is Raw => turn !== null)
262 const mode = options.mode ?? 'overview'
263 const rows: StepPaneRow[] = []
264 if (!activity.available || !turns.length) return [row('activity-empty', 'text', oneLine(activity.reason, 'event stream unavailable'), 'dim')]
265 const visibleCount = (options.width ?? 120) < 100 ? 5 : 10
266 const first = mode === 'overview' ? Math.max(0, turns.length - visibleCount) : 0
267 const followedTurn = turns.at(-1)
268 const selectedIndex = options.expandedTurn ?? numberOf(followedTurn?.index)
269 const runningCommand = record(activity.runningCommand)
270 const runningElapsed = runningCommand ? elapsedText(runningCommand.startedAt, options.nowMs) : null
271 if (mode === 'overview' && first > 0) {
272 const hidden = turns.slice(0, first)
273 const range = hidden.length === 1 ? 'turn 1' : `turns 1–${hidden.length}`
274 const counts = totalsOf(hidden)
275 rows.push(row('fold', 'fold', `${range}${counts ? ` · ${counts}` : ''} · click for detail`, 'dim', { opensDetail: true }))
276 }
277 turns.slice(first).forEach((turn, offset) => {
278 const index = numberOf(turn.index) ?? first + offset
279 const number = numberOf(turn.number) ?? index + 1
280 const selected = mode === 'overview' && selectedIndex === index
281 const prefix = `${selected ? '▶' : ' '}${String(number).padStart(2)} ${oneLine(turn.clock, '--:--')} `
282 const response = mode === 'overview'
283 ? clip(oneLine(turn.text, 'response summary unavailable'), Math.max(20, ((options.width ?? 120) - prefix.length) * 2))
284 : valueText(turn.text, 'response summary unavailable')
285 const head = `${prefix}${response}`
286 rows.push(row(`turn-${index}`, 'response', head, selected ? 'strong' : 'normal', { turnIndex: index, expandable: mode === 'overview', selected }))
287 if (mode === 'overview') {
288 const baseCounts = oneLine(turn.countsText, 'no tools').replace(/ · running:.*$/, '')
289 const counts = runningCommand && turn === followedTurn
290 ? `${baseCounts} · running: ${oneLine(runningCommand.text, 'command')}${runningElapsed ? ` ${runningElapsed}` : ''}`
291 : baseCounts
292 rows.push(row(`counts-${index}`, 'summary', turn.resultMarked === true ? '→ the report, shown under result' : counts, 'dim', { turnIndex: index, selected }))
293 if (selected) {
294 toolsFor(page, turn).filter(tool => !tool.inFlight).forEach((tool, toolIndex) => rows.push(row(`tool-${index}-${toolIndex}`, 'atomic', `${tool.clock ?? '--:--:--'} ${tool.command ? '$ ' : ''}${tool.text}${tool.duration ? ` ${tool.duration}` : ''}`, tool.error ? 'bad' : 'dim', { turnIndex: index })))
295 if (runningCommand && turn === followedTurn) {
296 const live = `⋮ running${runningElapsed ? ` ${runningElapsed}` : ''} ${oneLine(runningCommand.text, 'command')}`
297 rows.push(row(`running-${index}`, 'atomic', clip(live, Math.max(20, options.width ?? 120)), 'running', { turnIndex: index, selected: true }))
298 }
299 }
300 return
301 }
302 rows.push(row(`counts-${index}`, 'summary', oneLine(turn.countsText, 'no tools'), 'dim', { turnIndex: index }))
303 toolsFor(page, turn).forEach((tool, toolIndex) => rows.push(row(`tool-${index}-${toolIndex}`, 'atomic', `${tool.clock ?? '--:--:--'} ${tool.command ? '$ ' : ''}${tool.text}${tool.duration ? ` ${tool.duration}` : ''}`, tool.error ? 'bad' : 'dim', { turnIndex: index })))
304 })
305 return rows
306}
307
308function resultRows(page: Raw, fallback: BullswarmStep | null, options: StepPaneOptions): StepPaneRow[] {
309 const result = record(presentationOf(page).result) ?? {}
310 if (!Object.keys(result).length) return options.outputTail
311 ? options.outputTail.split(/\r?\n/).slice(0, 3).map((line, index) => row(`output-${index}`, 'text', line))
312 : [row('result-empty', 'text', fallback?.lastFailure ?? 'no report was written', 'dim')]
313 const rows: StepPaneRow[] = []
314 // A v3 step's checked answer leads its result, as on the dashboard.
315 const answer = record(result.answer)
316 if (answer && nullable(answer.text))
317 rows.push(row('answer', 'text', clip(oneLine(answer.text), Math.max(20, options.width ?? 120)), answer.ok === false ? 'bad' : 'normal'))
318 if (result.running === true) {
319 rows.push(row('not-yet', 'text', 'not yet', 'dim'))
320 const last = record(result.lastResponse)
321 if (last) rows.push(row('last-response', 'text', `${oneLine(last.clock)} ${valueText(last.text, '')}`))
322 } else list(result.reportLines).forEach((line, index) => rows.push(row(`report-${index}`, 'text', clip(oneLine(line, ''), Math.max(20, options.width ?? 120)))))
323 if (nullable(result.failure)) rows.push(row('failure', 'text', oneLine(result.failure), 'bad'))
324 list(result.changed).forEach((path, index) => rows.push(row(`changed-${index}`, 'artifact', `changed ${String(path)}`, 'dim')))
325 list(result.asks).forEach((ask, index) => rows.push(row(`asks-${index}`, 'text', `asks ${String(ask)}`, 'dim')))
326 const artifacts = record(result.artifacts) ?? {}
327 const fileBits = [result.runDirShort ? `run ${String(result.runDirShort)}` : null]
328 for (const name of ['task', 'output', 'stream', 'diff'] as const) {
329 const value = nullable(artifacts[name])
330 if (value) fileBits.push(name === 'stream' ? `${name} (${String(result.streamEvents ?? 0)} events)` : name)
331 }
332 if (fileBits.length) rows.push(row('files', 'artifact', `files ${fileBits.filter(Boolean).join(' · ')}`, 'dim'))
333 if (nullable(result.reportBytesText)) rows.push(row('result-open', 'text', `Enter on result: the full report, ${String(result.reportBytesText)}`, 'dim'))
334 return rows
335}
336
337function costRows(page: Raw): StepPaneRow[] {
338 const cost = record(presentationOf(page).cost) ?? {}
339 const rows: StepPaneRow[] = []
340 for (const value of list(cost.rows)) {
341 const entry = record(value) ?? {}
342 const label = oneLine(entry.label, 'cost')
343 const amount = oneLine(entry.amount)
344 const headline = oneLine(entry.headline, '')
345 const segments = [segment(label, label.endsWith(' plan') ? 'pool' : 'dim', label.replace(/ plan$/, '')), segment(' '), segment(amount, 'strong'), ...(headline ? [segment(` ${headline}`, 'dim')] : [])]
346 rows.push(row(`cost-${label}`, 'text', segments.map(part => part.text).join(''), 'normal', { segments }))
347 list(entry.details).forEach((detail, index) => rows.push(row(`cost-${label}-${index}`, 'text', ` ${String(detail)}`, 'dim')))
348 }
349 if (nullable(cost.basisLine)) rows.push(row('cost-basis', 'text', String(cost.basisLine), 'dim'))
350 return rows.length ? rows : [row('cost-empty', 'text', '—', 'dim')]
351}
352
353const TOGGLE_TEXT = 'overview · detail'
354
355/**
356 * The activity block's heading (rule 14): the heading word, the toggle straight
357 * after it, then the counts. A narrow pane drops count segments from the end,
358 * the filter control first; the word and the toggle are never cut. The current
359 * view is marked with two cells (`● ` in the pane, brackets in the frames).
360 */
361function headingWithToggle(label: string, segments: readonly string[], width: number): { lead: string; trail: string; title: string } {
362 const lead = `── ${label} · `
363 let keep = segments.length
364 const trailOf = (count: number): string => `${count ? ` · ${segments.slice(0, count).join(' · ')}` : ''} ──`
365 while (keep > 0 && lead.length + TOGGLE_TEXT.length + trailOf(keep).length + 2 > width) keep -= 1
366 const trail = trailOf(keep)
367 return { lead, trail, title: `${lead.slice(3)}${TOGGLE_TEXT}${trail.slice(0, -3)}` }
368}
369
370/** Shape the action-show record into the finished Step v2 pane. */
371export function shapeStep(value: unknown, options: StepPaneOptions = {}): StepPaneModel {
372 const page = stepPageOf(value)
373 const fallback = record(value) as BullswarmStep | null
374 const mode: StepMode = options.mode ?? (page.view === 'detail' ? 'detail' : 'overview')
375 const presentation = presentationOf(page)
376 const header = record(presentation.header) ?? {}
377 const result = record(presentation.result) ?? {}
378 const task = record(presentation.task) ?? {}
379 const activity = record(presentation.activity) ?? {}
380 const status = oneLine(header.status ?? fallback?.status, 'unknown')
381 const resultVerdict = nullable(result.verdictText)
382 const activityTotals = record(activity.totals) ?? {}
383 const turnCount = list(activity.turns).length
384 const commandCount = numberOf(activityTotals.commands) ?? 0
385 const editCount = numberOf(activityTotals.edits) ?? 0
386 const errorCount = numberOf(activityTotals.errors) ?? 0
387 const running = activity.running === true
388 const countSegments = [
389 `${turnCount} turn${turnCount === 1 ? '' : 's'}${running ? ' so far' : ''}`,
390 `${commandCount} cmds`, `${editCount} edits`, `${errorCount} err`,
391 ]
392 const activityHeading = headingWithToggle(mode === 'detail' ? 'transcript' : 'activity', mode === 'detail'
393 ? [...countSegments, 'showing all']
394 : [...countSegments, 'showing turns'], options.width ?? 120)
395 const activityTitle = activityHeading.title
396 const resultTitle = result.running === true ? 'now · not yet' : `result · ${status}${resultVerdict ? ` · ${resultVerdict}` : ''}`
397 const taskTitle = `task${task.lane ? ` · ${String(task.lane)}` : ''}${task.kind ? ` · ${String(task.kind)}` : ''}`
398 const cost = record(presentation.cost) ?? {}
399 const attemptCount = numberOf(cost.attemptCount) ?? 1
400 const blocksByKey: Record<StepPaneBlock['key'], StepPaneBlock> = {
401 header: { key: 'header', title: '', rows: headerRows(page, fallback, options) },
402 activity: { key: 'activity', title: activityTitle, rows: activityRows(page, { ...options, mode }) },
403 result: { key: 'result', title: resultTitle, rows: resultRows(page, fallback, options) },
404 task: { key: 'task', title: taskTitle, rows: taskRows(page, fallback, options) },
405 cost: { key: 'cost', title: attemptCount > 1 ? `cost · ${attemptCount} attempts` : 'cost', rows: costRows(page) },
406 }
407 const order: StepPaneBlock['key'][] = (options.width ?? 120) < 100
408 ? ['header', 'result', 'activity', 'task', 'cost']
409 : ['header', 'activity', 'result', 'task', 'cost']
410 const blocks = order.map(key => blocksByKey[key])
411 const sectionRow = (block: StepPaneBlock): StepPaneRow => block.key === 'activity'
412 ? row('activity-section', 'section', `${activityHeading.lead}${TOGGLE_TEXT}${activityHeading.trail}`, 'normal', { toggle: { lead: activityHeading.lead, trail: activityHeading.trail } })
413 : row(`${block.key}-section`, 'section', `── ${block.title} ──`)
414 const rows = blocks.flatMap(block => block.key === 'header' ? block.rows : [sectionRow(block), ...block.rows])
415 return {
416 mode,
417 actionId: oneLine(header.actionId ?? fallback?.id, 'step'),
418 runId: nullable(header.shortId),
419 blocks,
420 rows,
421 toggleLabel: mode === 'overview' ? 'detail' : 'overview',
422 toggleHint: mode === 'overview' ? 'v detail (every turn in full)' : 'v overview (latest turns)',
423 }
424}
425
426export const stepPaneModel = shapeStep
427
428/** Standalone tasks use a supplied Step model, or the same five-block grammar with honest absences. */
429export function taskStepPane(task: unknown, options: StepPaneOptions = {}): StepPaneModel {
430 const value = record(task) ?? {}
431 if (record(value.step) || record(value.page) || record(value.presentation)) return shapeStep(value, options)
432 const id = nullable(value.id) ?? nullable(value.taskFile)?.split(/[\\/]/).pop() ?? 'task'
433 const status = value.ok === true ? 'succeeded' : value.ok === false ? 'failed' : 'running'
434 return shapeStep({ presentation: {
435 header: { state: status === 'succeeded' ? 'ok' : status === 'failed' ? 'fail' : 'running', actionId: id, status, pool: value.pool, model: value.model, purpose: value.description ?? value.task },
436 activity: { available: false, reason: 'event stream unavailable', turns: [], totals: {} },
437 result: { running: status === 'running', reportLines: [], failure: value.reason, artifacts: { task: value.taskFile, output: value.outFile } },
438 task: { promptLines: options.promptPreview ?? [], owns: [], after: [], affects: [] },
439 cost: { attemptCount: 1, rows: [{ label: 'API rate', amount: '—', headline: 'no recorded rate', details: [] }, { label: `${String(value.pool ?? 'pool')} plan`, amount: '—', headline: 'no meter reading for this attempt', details: [] }] },
440 } }, options)
441}
442hooks/verdict.ts 223 lines1import type { BullswarmVerdict } from '../types'
2
3type Raw = Record<string, unknown>
4
5/**
6 * Finds the JSON document in a command's output: the whole text, or the
7 * last object that starts at a line start and parses to the end.
8 */
9export function jsonIn(text: string): Raw | null {
10 const trimmed = text.trim()
11 try {
12 const whole = JSON.parse(trimmed) as unknown
13 if (whole && typeof whole === 'object') return whole as Raw
14 } catch {
15 // fall through to a scan
16 }
17
18 const end = trimmed.lastIndexOf('}')
19 if (end < 0) return null
20 const starts: number[] = []
21 const re = /^\{/gm
22 let m: RegExpExecArray | null
23 while ((m = re.exec(trimmed))) starts.push(m.index)
24 for (const start of starts.reverse()) {
25 try {
26 const doc = JSON.parse(trimmed.slice(start, end + 1)) as unknown
27 if (doc && typeof doc === 'object') return doc as Raw
28 } catch {
29 // try the next start
30 }
31 }
32 return null
33}
34
35const str = (v: unknown): string | null => (typeof v === 'string' ? v : null)
36const num = (v: unknown): number | null =>
37 typeof v === 'number' && Number.isFinite(v) ? v : null
38
39/** A gate or loop a parked run waits at, as the waiting document lists it. */
40function waitingNodes(v: unknown): BullswarmVerdict['waitingFor'] {
41 if (!Array.isArray(v)) return []
42 return (v as Raw[])
43 .filter(n => typeof n?.id === 'string')
44 .map(n => ({ id: n.id as string, type: str(n.type) ?? 'gate', note: str(n.note) }))
45}
46
47const ANSWER_CHARS = 300
48
49function answerText(value: unknown): string {
50 const text = JSON.stringify(value) ?? 'null'
51 return text.length > ANSWER_CHARS ? `${text.slice(0, ANSWER_CHARS - 1)}…` : text
52}
53
54/** A run verdict's `notDone` (0.37), or null when absent or malformed. */
55function notDoneOf(v: unknown): BullswarmVerdict['notDone'] {
56 const raw = (v ?? null) as Raw | null
57 const count = num(raw?.count)
58 if (count === null || count <= 0) return null
59 const items = Array.isArray(raw?.items) ? (raw.items as unknown[]).filter((i): i is string => typeof i === 'string') : []
60 return { count, items }
61}
62
63/**
64 * Reads a `bullswarm run --json` or `bullswarm workflow goal --json`
65 * document into one verdict shape.
66 */
67export function parseVerdict(text: string, exitCode: number): BullswarmVerdict {
68 const doc = jsonIn(text)
69 const pick = (doc?.pick ?? null) as Raw | null
70 const meta = (doc?.meta ?? null) as Raw | null
71 // 0.37: a run's verdict carries a short `usage` summary; earlier ones kept
72 // the attempt's full usage under meta.usage.
73 const usage = (doc?.usage ?? meta?.usage ?? null) as Raw | null
74 const tokens = (usage?.tokens ?? null) as Raw | null
75 const check = (doc?.answerCheck ?? null) as Raw | null
76 const errors = Array.isArray(check?.errors) ? (check.errors as unknown[]) : []
77
78 return {
79 found: doc !== null && (typeof doc.ok === 'boolean' || doc.action === 'workflow-waiting'),
80 ok: doc?.ok === true,
81 why: str(doc?.why),
82 outFile: str(doc?.outFile),
83 pool: str(pick?.pool) ?? str(doc?.picked) ?? str(doc?.pool),
84 model: str(pick?.model) ?? str(doc?.model),
85 shortId: str(doc?.shortId),
86 wallSec: num(meta?.wallSec),
87 inputTokens: num(tokens?.standardRead),
88 outputTokens: num(tokens?.output),
89 answer: doc && 'answer' in doc ? (doc.answer ?? null) : null,
90 answerOk: typeof check?.ok === 'boolean' ? check.ok : null,
91 answerErrors: errors.map(e => (typeof e === 'string' ? e : str((e as Raw)?.message) ?? JSON.stringify(e))),
92 notDone: notDoneOf(doc?.notDone),
93 waitingFor: waitingNodes(doc?.waitingFor),
94 next: Array.isArray(doc?.next) ? (doc.next as unknown[]).filter((c): c is string => typeof c === 'string') : [],
95 exitCode,
96 raw: text,
97 }
98}
99
100/**
101 * The note appended to a Bash result that ran bullswarm: the verdict in the
102 * doctrine's words, so the model reads the output file instead of trusting
103 * the exit code.
104 */
105export function verdictContext(
106 verb: 'run' | 'workflow goal',
107 v: BullswarmVerdict,
108): string | null {
109 if (!v.found) return null
110 if (verb === 'workflow goal') {
111 if (v.waitingFor.length && v.shortId) return `bullswarm mod: ${waitingSentence(v.shortId, v.waitingFor, v.next)}`
112 return v.shortId
113 ? `bullswarm mod: workflow ${v.shortId} launched and detached. It stops for you only at a gate, a loop out of rounds or a step that needs you: run \`bullswarm workflow watch ${v.shortId} --until trouble\` (in the background when your harness wakes you when it exits, else in the foreground with \`--timeout\` below your tool's time limit; never end your turn while it runs), act on what wakes you, and relaunch it with the \`next:\` line it prints; \`/bullswarm\` shows the meters.`
114 : null
115 }
116 // A run given --answer-schema reports its answer, checked against the schema.
117 const answer = v.answerOk === true
118 ? ` Its answer ${answerText(v.answer)} (checked against its schema).`
119 : v.answerOk === false
120 ? ` Its answer check failed: ${v.answerErrors[0] ?? 'the answer does not match its schema'}.`
121 : ''
122 // What the worker's report says it left: a fact beside ok, not a failure.
123 const more = v.notDone ? v.notDone.count - v.notDone.items.length : 0
124 const notDone = v.notDone
125 ? ` The worker left ${v.notDone.count} item${v.notDone.count === 1 ? '' : 's'} not done${v.notDone.items.length ? `: ${v.notDone.items.join('; ')}${more > 0 ? ` (and ${more} more)` : ''}` : ''}.`
126 : ''
127 if (v.ok && v.outFile)
128 return `bullswarm mod: verdict ok from pool ${v.pool ?? '?'} (${v.model ?? '?'}); ${v.why ?? 'verified by content'}.${answer}${notDone} Read ${v.outFile} and check its content before using it — a clean exit code is not proof.`
129 if (!v.ok)
130 return `bullswarm mod: verdict ok=false — ${v.why ?? 'unknown failure'}.${answer} Inspect and report; do not retry blindly.`
131 return null
132}
133
134/** `2fne62 is waiting for you at gate approve (note). Nothing moves until …` */
135function waitingSentence(
136 token: string,
137 nodes: BullswarmVerdict['waitingFor'],
138 next: readonly string[],
139): string {
140 const named = nodes
141 .map(n => `${n.type} ${n.id}${n.note ? ` (${n.note})` : ''}`)
142 .join(', ')
143 const commands = next.length
144 ? next
145 : nodes.map(n => `bullswarm workflow continue ${token} ${n.id}${n.type === 'loop' ? ' --rounds <n>' : ''}`)
146 return `workflow ${token} is waiting for you at ${named}. Nothing behind it moves until you decide: ${commands.map(c => `\`${c}\``).join(' or ')}.`
147}
148
149/**
150 * The note appended to a `bullswarm workflow watch` or `workflow wait` result
151 * that shows a v3 run's gates, loops or answers, or null when it shows none:
152 * where the run waits and the command that moves it, the loop's round, and
153 * how many checked answers the output holds.
154 */
155export function watchContext(text: string): string | null {
156 const lines = text.split(/\r?\n/)
157 const parts: string[] = []
158 // Where the run waits: watch's `waiting: gate approve · note`, a stopped
159 // wait's ` waiting gate approve · note`, or wait's own fact line
160 // `⧖ gate approve waiting · note` / `⧖ loop fix waiting · round 3 of 3 · …`.
161 const waiting = lines
162 .map(l => {
163 const listed = /^waiting(?::\s*|\s+)(gate|loop) (\S+)(?: · (.*))?$/.exec(l.trim())
164 if (listed) return listed
165 const fact = /^\S+ (gate|loop) (\S+) waiting(?: · (.*?))?(?: · continue: .*)?$/.exec(l.trim())
166 if (!fact) return null
167 const rounds = fact[1] === 'loop' ? /round (\d+) of (\d+)/.exec(fact[3] ?? '') : null
168 if (rounds) fact[3] = `out of rounds (${rounds[1]!} of ${rounds[2]!})`
169 return fact
170 })
171 .filter((m): m is RegExpExecArray => m !== null)
172 .filter((m, i, all) => all.findIndex(o => o[1] === m[1] && o[2] === m[2]) === i)
173 const token = /bullswarm workflow continue (\S+) /.exec(text)?.[1] ?? null
174 const next = [
175 ...new Set(
176 lines
177 .map(l => /^(?:next:| or:|\s*continue)\s+(bullswarm workflow continue .*?)(?:\s{2,}\(.*)?$/.exec(l)?.[1] ?? null)
178 .filter((c): c is string => c !== null),
179 ),
180 ]
181 if (waiting.length && token) {
182 parts.push(
183 waitingSentence(
184 token,
185 waiting.map(m => ({ id: m[2]!, type: m[1]!, note: m[1] === 'gate' ? (m[3] ?? null) : null })),
186 next,
187 ),
188 )
189 for (const m of waiting.filter(w => w[1] === 'loop'))
190 parts.push(`The loop ${m[2]!} is ${(m[3] ?? 'out of rounds').replace(/;.*$/, '')}: --rounds gives it more; without --rounds it ends continued with its condition not met, which is no pass.`)
191 }
192 const loop = [...lines]
193 .reverse()
194 .map(l => {
195 const m = /loop (\S+) (passed (?:in|after) round \d+ of \d+|round \d+ of \d+|blocked in round \d+|continued by the caller after \d+ of \d+ rounds \(condition not met\))/.exec(l)
196 if (m) return m
197 // wait's fact line: `✓ loop fix passed · round 2 of 3`.
198 const fact = /loop (\S+) (passed|blocked) · round (\d+ of \d+)/.exec(l)
199 if (fact) fact[2] = `${fact[2]!} in round ${fact[3]!}`
200 return fact
201 })
202 .find((m): m is RegExpExecArray => m !== null)
203 if (loop && !waiting.some(w => w[1] === 'loop' && w[2] === loop[1]))
204 parts.push(`The loop ${loop[1]!} ${loop[2]!.startsWith('round') ? `is in ${loop[2]!}` : loop[2]!}.`)
205 const answers = lines.filter(l => /^\s+answer(?: \{|\[|$)/.test(l)).length
206 if (answers)
207 parts.push(`The output shows ${answers} checked answer${answers === 1 ? '' : 's'} (each matched its step's schema); read them as the steps' results, not the workers' prose.`)
208 return parts.length ? `bullswarm mod: ${parts.join(' ')}` : null
209}
210
211/**
212 * The model-facing note on an Agent call the mod routed to bullswarm. It names
213 * the pool and model, the verdict, what the worker left not done (0.37.2: only
214 * the on-screen notice said so before) and the output file to judge.
215 */
216export function routedContext(v: BullswarmVerdict, lane: string): string {
217 const more = v.notDone ? v.notDone.count - v.notDone.items.length : 0
218 const notDone = v.notDone
219 ? ` The worker left ${v.notDone.count} item${v.notDone.count === 1 ? '' : 's'} not done (its ## Not done section)${v.notDone.items.length ? `: ${v.notDone.items.join('; ')}${more > 0 ? ` (and ${more} more)` : ''}` : ''}.`
220 : ''
221 return `This Agent call was routed by the bullswarm mod to pool "${v.pool ?? '?'}" (model ${v.model ?? '?'}) on the ${lane} lane instead of a Claude subagent, because that pool had spare subscription quota. Bullswarm's verdict: ${v.why ?? 'ok'}.${notDone} The delegate's full output is saved at ${v.outFile}. Judge it by its content, as evidence, not as authority.`
222}
223hooks/pool-rows.tsx 117 lines1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import type { ElementTable, RenderElement } from 'claude-code'
5
6import type { BullswarmAssignment, BullswarmPool } from '../types'
7import { paceText, stateOf } from './pools'
8
9/**
10 * The meter colours, the truecolour values the status line renders with
11 * (herdr remaps the basic ANSI colours through its theme, so names would
12 * not agree with it): green below 50% used, amber from 50%, red from 80%.
13 */
14export const METER_GREEN = '#b6bd73'
15export const METER_AMBER = '#e9c880'
16export const METER_RED = '#bf6c69'
17
18/** The fill colour of a meter by how much is used, as the status line picks it. */
19export const severityColor = (usedPct: number | null): string =>
20 usedPct === null ? 'gray' : usedPct >= 80 ? METER_RED : usedPct >= 50 ? METER_AMBER : METER_GREEN
21
22const TRACK = '#3a3a3a'
23
24/**
25 * The bar as the status line draws it: background-coloured cells, filled
26 * up to used% in the severity colour over a dark track, and a white
27 * one-eighth line at the cell where elapsed% falls (the right edge of the
28 * last cell once the window has fully elapsed).
29 */
30export function meterBar(
31 Text: RowsUi['Text'],
32 usedPct: number | null,
33 elapsedPct: number | null,
34 width: number,
35 key?: string,
36): RenderElement {
37 if (usedPct === null) return <Text key={key} backgroundColor={TRACK}>{'·'.repeat(width)}</Text>
38 const fill = severityColor(usedPct)
39 const filled = Math.max(0, Math.min(width, Math.floor((usedPct / 100) * width)))
40 let mark = -1
41 let glyph = '▏'
42 if (elapsedPct !== null) {
43 mark = Math.floor((elapsedPct / 100) * width)
44 if (mark >= width) {
45 mark = width - 1
46 glyph = '▕'
47 }
48 }
49 const cells: RenderElement[] = []
50 let i = 0
51 while (i < width) {
52 const bg = i < filled ? fill : TRACK
53 if (i === mark) {
54 cells.push(
55 <Text key={`m${String(i)}`} color="white" backgroundColor={bg}>
56 {glyph}
57 </Text>,
58 )
59 i += 1
60 continue
61 }
62 let j = i
63 while (j < width && j !== mark && (j < filled) === (i < filled)) j += 1
64 cells.push(
65 <Text key={`c${String(i)}`} backgroundColor={bg}>
66 {' '.repeat(j - i)}
67 </Text>,
68 )
69 i = j
70 }
71 return <Text key={key}>{cells}</Text>
72}
73
74export type RowsUi = Pick<ElementTable<'terminal' | 'desktop'>, 'Box' | 'Text'>
75
76const NAME_WIDTH = 16
77const BAR_WIDTH = 10
78
79/**
80 * One row per enabled pool: display name, used-quota bar, used/elapsed,
81 * signed pace in the state's color, the actions running there. Shared by
82 * the strip and the pane.
83 */
84export function poolRows(
85 ui: RowsUi,
86 pools: readonly BullswarmPool[],
87 assignments: readonly BullswarmAssignment[],
88 names: ReadonlyMap<string, string>,
89): RenderElement[] {
90 const { Box, Text } = ui
91 return pools
92 .filter(p => p.enabled)
93 .map(p => {
94 const state = stateOf(p)
95 const name = (names.get(p.name) ?? p.name).padEnd(NAME_WIDTH).slice(0, NAME_WIDTH)
96 const used = p.usedPct === null ? ' —' : `${String(Math.round(p.usedPct)).padStart(3)}%`
97 const elapsed = p.elapsedPct === null ? '' : `/${String(Math.round(p.elapsedPct)).padStart(2)}%`
98 const busy = assignments.filter(a => a.pool === p.name)
99 const running = busy.length ? ` ${busy.map(a => a.actionId ?? a.lane).join(', ')}` : ''
100 return (
101 <Box key={p.name} flexDirection="row">
102 <Text wrap="truncate-end">
103 <Text>{name}</Text>
104 {meterBar(Text, p.usedPct, p.elapsedPct, BAR_WIDTH)}
105 <Text> {used}</Text>
106 <Text dimColor>{elapsed}</Text>
107 <Text color={state.color} bold>
108 {' '}
109 {p.quarantine ? 'quarantined' : paceText(p)}
110 </Text>
111 {running ? <Text color="cyan">{running}</Text> : null}
112 </Text>
113 </Box>
114 )
115 })
116}
117