SLOPSHOPPER

bullswarm

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…

newpanebandguardcommandtoast
★ 2v0.1.0MITupdated 2026-10-08Bulls-Work/bullswarm/mods/bullswarm
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bullswarm
│ ┃ bullswarm ✕ › fix the failing auth test and add an audit log call │ ┃ No ongoing workflow run. `bullswarm workflow │ ┃ goal` launches one. ⏺ Read(src/auth.ts) │ ┃ [ usage ] [ close ] ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /bullswarm │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · bullswarm
No ongoing workflow run. `bullswarm workflow goal` launches one. [ usage ] [ close ]
README

bullswarm as a Claude Mod

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:

HookWhat happens
engine.create$.bullswarm (pools, refresh, run) for any other plugin
session.startregisters /bullswarm, reads the meters, runs and in-flight ledger; re-reads every 2 min, every 20 s while work is in flight
prompt.contexta 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 Agenta 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 Basha 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 /bullswarmpools, runs, pane, open <step> [run], on, off, refresh, routed
ui.render AbovePromptthe 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.scrollthe 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__routea 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 onoff`
ui.closekeeps 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.

Source 13 files
hooks/register.ts 1051 lines
1import 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}
1051
hooks/host.ts 27 lines
1import 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}
27
hooks/pools.ts 225 lines
1import 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}
225
hooks/route.ts 83 lines
1import 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}
83
hooks/names.ts 67 lines
1/**
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}
67
hooks/overview.ts 52 lines
1/**
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}
52
hooks/pane.tsx 693 lines
1/* @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}
693
hooks/runs.ts 352 lines
1import 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}
352
hooks/strip.tsx 149 lines
1/* @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}
149
hooks/step.ts 442 lines
1import 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}
442
hooks/verdict.ts 223 lines
1import 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}
223
hooks/pool-rows.tsx 117 lines
1/* @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