Conductor: a live pane for orchestrated Claude Code sessions

A Claude Code mod: a live pane for orchestrated sessions. One main session dispatches tier-assigned subagents and Workflow runs and tracks the work as a task list. Conductor shows how that is going. It only watches session events: no network, no files, no processes, no model calls, and every event passes through unchanged.
| Row | Source |
|---|---|
| Progress | % done, bar, ETA and average task time, from the task list (TaskCreate / TaskUpdate), else workflow agents, else workers done/total. Once everything counted is done the row says done |
| Orchestrator box | main model and tier, context % (amber and red thresholds), ▶ now and → next |
| Worker boxes | one per subagent, coloured by tier: task, status, output tokens, steps, time bar. Any Agent call without an explicit model is flagged ⚠: ⚠ inherited when the worker runs the parent's model, the softer ⚠ no model · from definition when its agent definition chose one. Flagged workers count in N⚠ on the status line |
| Tiers strip | tokens per tier, with an estimated $ labelled est |
| Plan strip | one glyph per task: ▲ done, ◆ in progress, ◇ pending |
| Workflow strip | workflow name and phases from the script's meta, with agent counts |
| Servers strip | local servers this session started in the background (port, live / stopped / failed, last use, command), plus ○ in use for a localhost port it used but did not start. From background Bash calls, task notices, stop calls and localhost:<port> in later commands and URLs. Events only, no lsof |
| Architect strip | architect or advisor consults on a mini timeline |
| Gate strip | one cell per permission check, totals and denials |
| Log | spawns, completions, edits, errors, denials, and the turn receipt |
claude plugin marketplace add ~/Projects/conductor
claude plugin install conductor-mod@conductor-mod
/reload-plugins
/conductor-mod
Or add the marketplace from GitHub instead of the folder:
claude plugin marketplace add cheeky-amit/conductor-mod
/conductor-mod open | close | reset | layout auto|hybrid|minictrl+x tab. Then 1-8 expand a worker, and f / s / o open the gate's file / shell / other permission checks. The mini layout has no keys.Set these under pluginConfigs["conductor-mod"].options, or with /config.
| Key | Default | What it does | |
|---|---|---|---|
tiers | `T0=haiku,T1=sonnet,T2=opus,T3=fable\ | mythos` | Comma-separated TIER=regex (case-insensitive) mapping models to T0-T3 |
prices | `fable\ | mythos=10/50/0.25,opus=4/20/0.2,sonnet=2/10/0.2,haiku=1/5/0.1` | Comma-separated regex=input/output/cacheRead in $ per 1M tokens, for estimates only |
contextAmber | 70 | Context gauge turns amber at this percent | |
contextRed | 80 | Context gauge turns red and says 'checkpoint now' at this percent | |
panels | progress,crew,plan,workflow,servers,architect,gate,log | Comma-separated, in order: progress, crew, plan, workflow, servers, architect, gate, log. A panel left out is hidden | |
layout | auto | auto: mini inline above the prompt or under 40 columns, hybrid otherwise. hybrid: hybrid on every docked pane (inline above the prompt is always mini, 8 rows). mini: mini everywhere | |
maxBoxes | 3 | Worker boxes side by side before they fold into rows (1-6) | |
architectPattern | `advisor\ | architect` | Case-insensitive regex for agent types and server tools that count as the architect |
architectLabel | ARCHITECT | Name shown for the architect | |
gateLabel | GATE | Name shown for the permission strip | |
palette | theme | theme follows your Claude Code theme; pastel is fixed colours for dark terminals | |
openOnStart | true | Open the pane when a session starts | |
statusLine | true | Show progress, ETA, context, running workers, flags and live servers in the status line |
Everything else is read from an event. These are guesses, and the pane labels them.
likely-inherited: a worker whose model equals its parent's, when its Agent call could not be linked to the spawn. Flagged ⚠. An unlinked spawn on another model is not flagged: whether a model was passed is unknown.est $: tokens times your prices table. Not a bill.— until one task finishes.before a plan, before done, error repeats): guessed from the edits and error streak so far in the turn.ETA — until the run ends.ID: <token> in a background Bash result (4 to 32 letters, digits, _, -).KillShell, KillBash and TaskStop, taking the job id as bash_id, shell_id, task_id or id.PORT= or http.server N (or a bare host:port, as in php -S localhost:8000), read from the part of a chained command that is the server. Another command in the chain (curl localhost:8000 && npm run dev) or a NAME=value URL does not set it.Flightdeck by Stephen Casella (MIT) is the reference. Conductor's gate, architect, receipt and log logic is ported from it, marked "Ported from Flightdeck v0.4.0" in each file. See NOTICE. Conductor is MIT, see LICENSE.
hooks/register.tsx 599 lines1// Event wiring only: each hook passes its event on, then applies a pure reducer to state.
2
3import type { EngineInterface, Register } from 'claude-code'
4import { atom, read, update } from 'claude-code'
5import type { Bucket, Check, Consult, Layout, LogLine, PlanTask, Server, WorkflowRun } from '../types'
6import {
7 adviceOf,
8 afterCall,
9 DEFAULT_ARCHITECT,
10 DEFAULT_TURN,
11 endConsult,
12 handbackOf,
13 momentOf,
14 receiptOf,
15 startConsult,
16} from './model/architect'
17import type { Config } from './model/config'
18import { contextLevel, PALETTES, parseConfig } from './model/config'
19import { addWorker, completeWorker, nextN, normalizeWorker, noteWorkerTool, stepWorker, updateWorker } from './model/crew'
20import { DEFAULT_GATE, normalizeGate, recordCheck, settleCheck } from './model/gate'
21import { failureLine, normalizeLog, promptLine, pushLog, renderFailure } from './model/log'
22import type { TaskEvent } from './model/plan'
23import { applyTaskEvent, taskFromCreate, taskFromTodoWrite, taskFromUpdate } from './model/plan'
24import { progressOf, statusText } from './model/progress'
25import { liveServers, mayAffectServers, serversAfterCall, serversAfterNotice } from './model/servers'
26import { isFlagged, modelSourceOf } from './model/tiers'
27import { bucketOf, describeInput, EDIT_TOOLS, listOf, normalize, prettyModel, redact, shorten } from './model/util'
28import { activeRun, endRunByText, finishRunAgent, loopPhase, runRefOf, startRun, stepRunAgent, workflowMetaOf } from './model/workflow'
29import { DEFAULT_MAIN, DEFAULT_ROSTER, DEFAULT_USAGE, DEFAULT_VIEW, SCHEMA_VERSION } from './store'
30import { drawPane } from './view/pane'
31
32const PANE = 'conductor-mod'
33const TITLE = 'Conductor'
34const PANE_COLUMNS = 66
35const metaState = atom({ plugin: 'conductor-mod', key: 'meta' } as const, { schemaVersion: SCHEMA_VERSION })
36const mainState = atom({ plugin: 'conductor-mod', key: 'main' } as const, DEFAULT_MAIN)
37const usageState = atom({ plugin: 'conductor-mod', key: 'usage' } as const, DEFAULT_USAGE)
38const architectState = atom({ plugin: 'conductor-mod', key: 'architect' } as const, DEFAULT_ARCHITECT)
39const gateState = atom({ plugin: 'conductor-mod', key: 'gate' } as const, DEFAULT_GATE)
40const workersState = atom({ plugin: 'conductor-mod', key: 'workers' } as const, [])
41const planState = atom({ plugin: 'conductor-mod', key: 'plan' } as const, [])
42const runsState = atom({ plugin: 'conductor-mod', key: 'runs' } as const, [])
43const logState = atom({ plugin: 'conductor-mod', key: 'log' } as const, [])
44const turnState = atom({ plugin: 'conductor-mod', key: 'turn' } as const, DEFAULT_TURN)
45const receiptState = atom({ plugin: 'conductor-mod', key: 'receipt' } as const, null)
46const viewState = atom({ plugin: 'conductor-mod', key: 'view' } as const, DEFAULT_VIEW)
47const rosterState = atom({ plugin: 'conductor-mod', key: 'roster' } as const, DEFAULT_ROSTER)
48const serversState = atom({ plugin: 'conductor-mod', key: 'servers' } as const, [])
49
50type $ = EngineInterface
51type ServerBlock = { type: string; id?: string; name?: string; tool_use_id?: string }
52
53// Every read goes through these, so a value saved under an older shape still reads.
54async function getMain($: $) {
55 return normalize(DEFAULT_MAIN, await read($, mainState))
56}
57async function getUsage($: $) {
58 return normalize(DEFAULT_USAGE, await read($, usageState))
59}
60async function getArchitect($: $) {
61 const a = normalize(DEFAULT_ARCHITECT, await read($, architectState))
62 return { ...a, consults: listOf<Consult>(a.consults), ids: listOf<string>(a.ids), seen: listOf<string>(a.seen) }
63}
64async function getGate($: $) {
65 return normalizeGate(await read($, gateState))
66}
67async function getWorkers($: $) {
68 return listOf<unknown>(await read($, workersState)).map(normalizeWorker)
69}
70async function getPlan($: $) {
71 return listOf<PlanTask>(await read($, planState))
72}
73async function getRuns($: $) {
74 return listOf<WorkflowRun>(await read($, runsState))
75}
76async function getLog($: $) {
77 return normalizeLog(await read($, logState))
78}
79async function getTurn($: $) {
80 return normalize(DEFAULT_TURN, await read($, turnState))
81}
82async function getReceipt($: $) {
83 return (await read($, receiptState)) ?? null
84}
85async function getView($: $) {
86 return normalize(DEFAULT_VIEW, await read($, viewState))
87}
88async function getRoster($: $) {
89 return { architectTypes: listOf<string>(normalize(DEFAULT_ROSTER, await read($, rosterState)).architectTypes) }
90}
91async function getServers($: $) {
92 return listOf<Server>(await read($, serversState))
93}
94
95async function say($: $, who: string, text: string, kind: LogLine['kind'] = 'info', agentId: string | null = null) {
96 const line: LogLine = { at: await $.clock.now(), who, text, kind, agentId }
97 await update($, logState, list => pushLog(normalizeLog(list), line))
98}
99
100/** Runs a hook's bookkeeping; a failure becomes one error row in the log, and the event has already passed on. */
101async function safely($: $, hook: string, f: () => Promise<void>) {
102 try {
103 await f()
104 } catch (err) {
105 await say($, 'conductor-mod', failureLine(hook, err), 'error').catch(() => undefined)
106 }
107}
108
109async function resetAll($: $) {
110 await update($, mainState, m => ({ ...DEFAULT_MAIN, model: normalize(DEFAULT_MAIN, m).model }))
111 await update($, usageState, () => DEFAULT_USAGE)
112 await update($, architectState, () => DEFAULT_ARCHITECT)
113 await update($, gateState, () => DEFAULT_GATE)
114 await update($, workersState, () => [])
115 await update($, planState, () => [])
116 await update($, runsState, () => [])
117 await update($, logState, () => [])
118 await update($, turnState, () => DEFAULT_TURN)
119 await update($, receiptState, () => null)
120 await update($, viewState, () => DEFAULT_VIEW)
121 await update($, serversState, () => [])
122}
123
124/** A stored shape older than this build's starts fresh: the pane is session-only. */
125async function migrate($: $) {
126 const m = await read($, metaState)
127 if ((m?.schemaVersion ?? 0) >= SCHEMA_VERSION) return
128 await resetAll($)
129 await update($, metaState, () => ({ schemaVersion: SCHEMA_VERSION }))
130}
131
132const textOf = (ran: unknown) => {
133 const r = ran as { text?: unknown; result?: unknown }
134 return typeof r.text === 'string' ? r.text : JSON.stringify(r.result ?? '')
135}
136
137async function refreshStatus($: $, cfg: Config) {
138 if (!cfg.statusLine) return $.ui.status(undefined)
139 const [workers, plan, runs, usage, servers, now] = await Promise.all([
140 getWorkers($),
141 getPlan($),
142 getRuns($),
143 getUsage($),
144 getServers($),
145 $.clock.now(),
146 ])
147 const progress = progressOf({ tasks: plan, run: activeRun(runs) ?? runs[runs.length - 1] ?? null, workers, now })
148 $.ui.status(
149 statusText({
150 progress,
151 ctxPct: usage.pct,
152 running: workers.filter(w => w.status === 'running').length,
153 flagged: workers.filter(w => isFlagged(w.modelSource)).length,
154 servers: liveServers(servers).length,
155 }),
156 )
157}
158
159async function consultStarted($: $, cfg: Config, id: string, rawVia: string) {
160 // `via` can be an agent type from the Agent call's input, so it is masked before it is stored or logged.
161 const via = redact(rawVia)
162 const t = await getTurn($)
163 const moment = momentOf(t)
164 const at = await $.clock.now()
165 await update($, architectState, a => startConsult(normalize(DEFAULT_ARCHITECT, a), { id, at, moment, via }))
166 if (moment === 'before done') await update($, turnState, x => ({ ...normalize(DEFAULT_TURN, x), isReviewing: true }))
167 await say($, cfg.architectLabel.toLowerCase(), `${moment} · ${via} (inferred)`, 'consult')
168}
169
170async function consultEnded($: $, cfg: Config, advice: string | null, id?: string) {
171 const at = await $.clock.now()
172 const text = advice ? adviceOf(advice) : null
173 await update($, architectState, a => endConsult(normalize(DEFAULT_ARCHITECT, a), at, text || null, id))
174 await update($, turnState, t => ({ ...normalize(DEFAULT_TURN, t), isReviewing: false }))
175 await say($, cfg.architectLabel.toLowerCase(), text ? `advice: ${shorten(text, 60)}` : 'advice returned', 'consult')
176}
177
178async function whoIs($: $, agentId: string | undefined) {
179 if (!agentId) return 'main'
180 const w = (await getWorkers($)).find(x => x.id === agentId)
181 return w ? `W${w.n}` : 'agent'
182}
183
184/** The press handler for one worker's button: opens that worker, or closes it when it is already open. Built here, since it holds `$`. */
185const expandOnPress = ($: $, id: string) => () =>
186 update($, viewState, x => ({ ...normalize(DEFAULT_VIEW, x), expanded: normalize(DEFAULT_VIEW, x).expanded === id ? null : id }))
187
188/** The press handler for one gate bucket (`f`, `s`, `o`): opens its drill-down, or closes it when it is already open. Built here, since it holds `$`. */
189const gateOnPress = ($: $, b: Bucket) => () =>
190 update($, viewState, x => ({ ...normalize(DEFAULT_VIEW, x), gateOpen: normalize(DEFAULT_VIEW, x).gateOpen === b ? null : b }))
191
192async function openPane($: $) {
193 return $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS, rows: 8 })
194}
195
196export const register: Register = (on, options) => {
197 const cfg = parseConfig(options)
198 const C = PALETTES[cfg.palette]
199 // tool.check carries no loop id; the tool.call around it does. An Agent call's model, by its call id.
200 const callLoop = new Map<string, string | null>()
201 const requested = new Map<string, string | null>()
202
203 on('session.start', async ($, e, next) => {
204 await safely($, 'session.start', async () => {
205 await $.command.register({
206 name: 'conductor-mod',
207 description: 'Conductor pane: open, close, reset, or set the layout',
208 argumentHint: '[open|close|reset|layout auto|hybrid|mini]',
209 })
210 await migrate($)
211 const u = await $.session.usage().catch(() => null)
212 if (u)
213 await update($, usageState, () => ({ pct: u.context.percent ?? null, tokens: u.context.tokens ?? null, window: u.context.window }))
214 if (cfg.openOnStart) void openPane($).catch(() => undefined)
215 await refreshStatus($, cfg)
216 })
217 return next(e)
218 })
219
220 on('session.end', async ($, e, next) => {
221 if (e.reason === 'clear')
222 await safely($, 'session.end', async () => {
223 await resetAll($)
224 await refreshStatus($, cfg)
225 })
226 return next(e)
227 })
228
229 on('command.run', { command: 'conductor-mod' }, async ($, e) => {
230 try {
231 const [verb = 'open', arg = ''] = e.args.trim().split(/\s+/)
232 if (verb === 'close') {
233 await $.ui.close({ id: PANE })
234 return { text: 'conductor-mod closed.' }
235 }
236 if (verb === 'reset') {
237 await resetAll($)
238 await refreshStatus($, cfg)
239 return { text: 'conductor-mod reset.' }
240 }
241 if (verb === 'layout') {
242 const layout: Layout | null = arg === 'auto' || arg === 'hybrid' || arg === 'mini' ? arg : null
243 if (!layout) return { text: 'Usage: /conductor-mod layout auto|hybrid|mini' }
244 await update($, viewState, v => ({ ...normalize(DEFAULT_VIEW, v), layout }))
245 }
246 const opened = await openPane($)
247 return {
248 text: opened.isPlaced
249 ? 'conductor-mod open. In the hybrid layout, focus it with ctrl+x tab; 1-8 expand workers, f/s/o open the file/shell/other permission rows.'
250 : `conductor-mod is not shown yet: ${opened.reason}`,
251 }
252 } catch (err) {
253 return { text: `conductor-mod failed: ${shorten(err instanceof Error ? err.message : String(err), 80)}` }
254 }
255 })
256
257 on('agent.offer', async ($, e, next) => {
258 const offered = await next(e)
259 if (cfg.architect.test(e.agent))
260 await safely($, 'agent.offer', async () => {
261 await update($, rosterState, r => {
262 const x = listOf<string>(normalize(DEFAULT_ROSTER, r).architectTypes)
263 return x.includes(e.agent) ? { architectTypes: x } : { architectTypes: [...x, e.agent].slice(-20) }
264 })
265 })
266 return offered
267 })
268
269 on('turn.start', async ($, e, next) => {
270 await safely($, 'turn.start', async () => {
271 const now = await $.clock.now()
272 const cost = (await $.session.usage().catch(() => null))?.cost?.usd ?? null
273 await update($, turnState, () => ({ ...DEFAULT_TURN, startedAt: now, costAtStart: cost }))
274 await update($, mainState, m => ({ ...normalize(DEFAULT_MAIN, m), isRunning: true }))
275 const text = e.text ?? ''
276 const back = text ? handbackOf(text) : null
277 const a = back ? await getArchitect($) : null
278 if (back && a && a.ids.includes(back.from)) {
279 const advice = adviceOf(back.body)
280 if (advice && advice !== a.lastAdvice) {
281 await update($, architectState, x => ({ ...normalize(DEFAULT_ARCHITECT, x), lastAdvice: advice }))
282 await say($, cfg.architectLabel.toLowerCase(), `advice: ${shorten(advice, 60)}`, 'consult')
283 }
284 } else if (text) {
285 if (/^\s*<task-notification/i.test(text)) {
286 await update($, runsState, r => endRunByText(listOf<WorkflowRun>(r), text, now))
287 await update($, serversState, l => serversAfterNotice(listOf<Server>(l), text, now))
288 }
289 const p = promptLine(text)
290 await say($, p.who, p.text)
291 }
292 // A background server's stop notice changes the live count the status line shows.
293 await refreshStatus($, cfg)
294 })
295 return next(e)
296 })
297
298 on('turn.step', async function* ($, e, next) {
299 const result = yield* next(e)
300 await safely($, 'turn.step', async () => {
301 if (!e.agentId) {
302 await update($, mainState, m => {
303 const x = normalize(DEFAULT_MAIN, m)
304 return { ...x, model: e.model, effort: String(e.effort ?? x.effort), steps: x.steps + 1 }
305 })
306 return
307 }
308 const id = e.agentId
309 const [workers, a] = await Promise.all([getWorkers($), getArchitect($)])
310 if (workers.some(w => w.id === id)) {
311 await update($, workersState, list =>
312 updateWorker(listOf<unknown>(list).map(normalizeWorker), id, w =>
313 stepWorker(w, { model: e.model, usage: result.usage, stopReason: result.stopReason }),
314 ),
315 )
316 if (result.stopReason === 'max_tokens') await say($, await whoIs($, id), 'hit max_tokens', 'error', id)
317 } else if (!a.ids.includes(id)) {
318 const at = await $.clock.now()
319 await update($, runsState, r => stepRunAgent(listOf<WorkflowRun>(r), { agentId: id, phase: loopPhase(e), at }))
320 }
321 })
322 return result
323 })
324
325 on('session.measure', async ($, e, next) => {
326 await safely($, 'session.measure', async () => {
327 const before = contextLevel((await getUsage($)).pct, cfg)
328 const pct = e.context.percent ?? null
329 await update($, usageState, () => ({ pct, tokens: e.context.tokens ?? null, window: e.context.window }))
330 if (before !== 'red' && contextLevel(pct, cfg) === 'red') await say($, 'context', `past ${cfg.contextRed}% · checkpoint now`, 'error')
331 if (e.changed.includes('context')) await refreshStatus($, cfg)
332 })
333 return next(e)
334 })
335
336 on('session.compact', async ($, e, next) => {
337 const done = await next(e)
338 if (!e.agentId && e.trigger !== 'precompute')
339 await safely($, 'session.compact', () => say($, 'main', `context compacted (${e.trigger})`))
340 return done
341 })
342
343 on('tool.check', async ($, e, next) => {
344 const verdict = await next(e)
345 if (e.tool_use_id)
346 await safely($, 'tool.check', async () => {
347 const check: Check = {
348 id: e.tool_use_id as string,
349 tool: e.tool,
350 bucket: bucketOf(e.tool),
351 verdict: verdict.decision === 'allow' ? 'rule' : verdict.decision,
352 inSubagent: Boolean(callLoop.get(e.tool_use_id as string)),
353 detail: shorten(describeInput(e.tool, e.input), 90),
354 at: await $.clock.now(),
355 }
356 await update($, gateState, g => recordCheck(normalizeGate(g), check))
357 if (verdict.decision === 'deny') await say($, 'gate', `denied by rule · ${check.detail}`, 'error')
358 })
359 return verdict
360 })
361
362 on('tool.call', async ($, e, next) => {
363 callLoop.set(e.tool_use_id, e.agentId ?? null)
364 const input = e as unknown as Record<string, unknown>
365 if (e.tool === 'Agent') requested.set(e.tool_use_id, typeof input.model === 'string' && input.model ? input.model : null)
366 const ran = await next(e).finally(() => {
367 callLoop.delete(e.tool_use_id)
368 requested.delete(e.tool_use_id)
369 })
370 await safely($, 'tool.call', async () => {
371 const didRun = ran.deny === undefined
372 const now = await $.clock.now()
373 await update($, gateState, g => settleCheck(normalizeGate(g), e.tool_use_id, didRun))
374 const tool = String(e.tool)
375 // Every tool can start, stop or use a local server, so this runs before any branch below returns; the cheap check skips the rest.
376 if (didRun && mayAffectServers(tool, input)) {
377 let liveBefore = 0
378 const servers = await update($, serversState, l => {
379 const list = listOf<Server>(l)
380 liveBefore = liveServers(list).length
381 return serversAfterCall(list, {
382 tool,
383 input,
384 resultText: textOf(ran),
385 didRun,
386 at: now,
387 by: e.agentId ? 'agent' : 'main',
388 callId: e.tool_use_id,
389 })
390 })
391 if (liveServers(listOf<Server>(servers)).length !== liveBefore) await refreshStatus($, cfg)
392 }
393 if (tool === 'SubagentHandback') {
394 const message = input.message
395 const a = e.agentId ? await getArchitect($) : null
396 if (a && e.agentId && a.ids.includes(e.agentId) && typeof message === 'string') {
397 const advice = adviceOf(message)
398 if (advice && advice !== a.lastAdvice) {
399 await update($, architectState, x => ({ ...normalize(DEFAULT_ARCHITECT, x), lastAdvice: advice }))
400 await say($, cfg.architectLabel.toLowerCase(), `advice: ${shorten(advice, 60)}`, 'consult')
401 }
402 }
403 return
404 }
405 if (tool === 'Agent') return
406 if (didRun && (tool === 'TaskCreate' || tool === 'TaskUpdate' || tool === 'TodoWrite')) {
407 const evs: TaskEvent[] =
408 tool === 'TaskCreate'
409 ? [taskFromCreate(input, textOf(ran), now)].filter((x): x is TaskEvent => x !== null)
410 : tool === 'TaskUpdate'
411 ? taskFromUpdate(input, now)
412 : [taskFromTodoWrite(input, now)].filter((x): x is TaskEvent => x !== null)
413 if (evs.length > 0) {
414 await update($, planState, p => evs.reduce(applyTaskEvent, listOf<PlanTask>(p)))
415 await refreshStatus($, cfg)
416 }
417 return
418 }
419 if (didRun && tool === 'Workflow') {
420 const meta = workflowMetaOf(input)
421 await update($, runsState, r =>
422 startRun(listOf<WorkflowRun>(r), { id: e.tool_use_id, ref: runRefOf(textOf(ran)), ...meta, at: now }),
423 )
424 await say($, 'workflow', `started · ${meta.name}${meta.phases.length > 0 ? ` · ${meta.phases.join(' › ')}` : ''}`)
425 return
426 }
427 const hasFailed = didRun && ran.isError === true
428 const isEdit = !hasFailed && didRun && EDIT_TOOLS.has(tool)
429 await update($, turnState, t => afterCall(normalize(DEFAULT_TURN, t), { inSubagent: Boolean(e.agentId), hasFailed, isEdit }))
430 const text = shorten(describeInput(tool, e), 64)
431 if (e.agentId) {
432 const id = e.agentId
433 await update($, workersState, list =>
434 updateWorker(listOf<unknown>(list).map(normalizeWorker), id, w =>
435 noteWorkerTool(w, { tool, text, isError: hasFailed || !didRun }),
436 ),
437 )
438 } else {
439 await update($, mainState, m => ({ ...normalize(DEFAULT_MAIN, m), activity: text }))
440 }
441 if (!didRun) await say($, await whoIs($, e.agentId), `${text} denied`, 'error', e.agentId ?? null)
442 else if (hasFailed) await say($, await whoIs($, e.agentId), `${text} ✗`, 'error', e.agentId ?? null)
443 else if (isEdit) await say($, await whoIs($, e.agentId), text, 'info', e.agentId ?? null)
444 })
445 return ran
446 })
447
448 // A server-side review tool never reaches tool.call: it shows only in the assistant's rows.
449 on('session.append', async ($, e, next) => {
450 if (!e.agentId && e.message.type === 'assistant')
451 await safely($, 'session.append', async () => {
452 const a = await getArchitect($)
453 const opened = new Set<string>()
454 for (const block of e.message.content as unknown as readonly ServerBlock[]) {
455 if (block.type === 'server_tool_use' && block.name && block.id && cfg.architect.test(block.name)) {
456 if (a.seen.includes(block.id)) continue
457 const id = block.id
458 await update($, architectState, x => {
459 const y = normalize(DEFAULT_ARCHITECT, x)
460 return { ...y, seen: [...listOf<string>(y.seen), id].slice(-60) }
461 })
462 opened.add(id)
463 await consultStarted($, cfg, id, `${block.name} tool`)
464 } else if (block.type.endsWith('_tool_result') && block.tool_use_id) {
465 const id = block.tool_use_id
466 const isOpen = opened.has(id) || (await getArchitect($)).consults.some(c => c.id === id && c.endAt === null)
467 if (isOpen) await consultEnded($, cfg, null, id)
468 }
469 }
470 })
471 return next(e)
472 })
473
474 on('agent.spawn', async ($, e, next) => {
475 const started = await next(e)
476 if (started.deny !== undefined || !started.agentId) return started
477 const id = started.agentId
478 await safely($, 'agent.spawn', async () => {
479 const roster = await getRoster($)
480 if (cfg.architect.test(e.subagentType) || roster.architectTypes.includes(e.subagentType)) {
481 await update($, architectState, a => {
482 const x = normalize(DEFAULT_ARCHITECT, a)
483 return { ...x, ids: [...listOf<string>(x.ids), id].slice(-40) }
484 })
485 await consultStarted($, cfg, id, e.subagentType.split(':').pop() ?? 'agent')
486 return
487 }
488 const callId = (e as unknown as { tool_use_id?: unknown }).tool_use_id
489 const req = typeof callId === 'string' && requested.has(callId) ? (requested.get(callId) ?? null) : undefined
490 const parent = String((e as unknown as { parentModel?: unknown }).parentModel ?? (await getMain($)).model)
491 const workers = await getWorkers($)
492 const model = started.model ?? ''
493 const worker = {
494 ...normalizeWorker({}),
495 id,
496 n: nextN(workers),
497 type: redact(e.name ?? e.subagentType),
498 description: shorten(redact(e.description ?? ''), 120),
499 model,
500 modelSource: modelSourceOf({ requested: req, resolved: model, parent }),
501 spawnedAt: await $.clock.now(),
502 }
503 await update($, workersState, list => addWorker(listOf<unknown>(list).map(normalizeWorker), worker))
504 await say($, `W${worker.n}`, `spawned · ${worker.type} · ${prettyModel(worker.model)}`, 'info', id)
505 if (worker.modelSource === 'inherited')
506 await say($, `W${worker.n}`, `⚠ inherited ${prettyModel(worker.model)} (no model passed)`, 'error', id)
507 if (worker.modelSource === 'likely-inherited')
508 await say($, `W${worker.n}`, `⚠ likely inherited ${prettyModel(worker.model)}`, 'error', id)
509 // Softer than an inherited model: the agent's own definition chose it, so it is a note and not a warning colour.
510 if (worker.modelSource === 'definition-unpassed')
511 await say($, `W${worker.n}`, `⚠ no model · from definition ${prettyModel(worker.model)}`, 'info', id)
512 await refreshStatus($, cfg)
513 })
514 return started
515 })
516
517 on('turn.complete', async ($, e, next) => {
518 const done = await next(e)
519 await safely($, 'turn.complete', async () => {
520 const id = e.agentId
521 const now = await $.clock.now()
522 if (!id) {
523 const [t, workers] = await Promise.all([getTurn($), getWorkers($)])
524 const cost = (await $.session.usage().catch(() => null))?.cost?.usd ?? null
525 await update($, receiptState, () =>
526 receiptOf(t, {
527 durationMs: e.durationMs,
528 agentsSince: workers.filter(w => w.spawnedAt >= t.startedAt).length,
529 costNow: cost,
530 reason: e.reason,
531 }),
532 )
533 await update($, mainState, m => ({ ...normalize(DEFAULT_MAIN, m), isRunning: false }))
534 } else if ((await getArchitect($)).ids.includes(id)) {
535 await consultEnded($, cfg, e.answer, id)
536 } else if ((await getWorkers($)).some(w => w.id === id)) {
537 await update($, workersState, list =>
538 updateWorker(listOf<unknown>(list).map(normalizeWorker), id, w =>
539 completeWorker(w, { reason: e.reason, at: now, answer: e.answer }),
540 ),
541 )
542 const w = (await getWorkers($)).find(x => x.id === id)
543 if (w)
544 await say(
545 $,
546 `W${w.n}`,
547 w.status === 'done' ? `done · ${shorten(w.description, 40)}` : w.status,
548 w.status === 'done' ? 'done' : 'error',
549 id,
550 )
551 } else {
552 await update($, runsState, r => finishRunAgent(listOf<WorkflowRun>(r), id, now))
553 }
554 await refreshStatus($, cfg)
555 })
556 return done
557 })
558
559 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
560 const els = $.ui.resolve(e)
561 try {
562 const [main, usage, workers, plan, runs, architect, gate, log, turn, receipt, view, servers, now] = await Promise.all([
563 getMain($),
564 getUsage($),
565 getWorkers($),
566 getPlan($),
567 getRuns($),
568 getArchitect($),
569 getGate($),
570 getLog($),
571 getTurn($),
572 getReceipt($),
573 getView($),
574 getServers($),
575 $.clock.now(),
576 ])
577 return drawPane(
578 { els, C, now, hasClient: 'Client' in els, cfg },
579 { main, usage, workers, plan, runs, architect, gate, log, turn, receipt, view, servers },
580 {
581 columns: e.props.bodyColumns,
582 isInline: e.props.placement === 'inline',
583 bodyRows: e.props.scroll?.bodyRows ?? e.viewport?.rows ?? 40,
584 viewedAgent: e.props.view?.agentId ?? null,
585 onExpand: (id: string) => expandOnPress($, id),
586 onGate: (b: Bucket) => gateOnPress($, b),
587 },
588 )
589 } catch (err) {
590 // A drawing that fails still answers: one row saying so, cut to the pane's width.
591 return (
592 <els.Text color={C.warn} wrap="truncate">
593 {renderFailure(err, e.props.bodyColumns)}
594 </els.Text>
595 )
596 }
597 })
598}
599hooks/model/architect.ts 109 lines1// Ported from Flightdeck v0.4.0 (MIT, Stephen Casella), hooks/core.ts.
2// Pure: nothing here touches `$`.
3import type { Architect, Moment, Receipt, Turn } from '../../types'
4import { fmtDuration, redact, shorten } from './util'
5
6export const DEFAULT_ARCHITECT: Architect = { consults: [], ids: [], seen: [], lastAdvice: '' }
7export const DEFAULT_TURN: Turn = { edits: 0, errorStreak: 0, errors: 0, isReviewing: false, startedAt: 0, costAtStart: null }
8
9/**
10 * The turn after one tool call. Errors count in the main loop only (a subagent's failure is its
11 * own); edits count from every loop, so delegated work still reaches "before done".
12 */
13export const afterCall = (t: Turn, c: { inSubagent: boolean; hasFailed: boolean; isEdit: boolean }): Turn => ({
14 ...t,
15 errorStreak: c.inSubagent ? t.errorStreak : c.hasFailed ? t.errorStreak + 1 : 0,
16 errors: t.errors + (!c.inSubagent && c.hasFailed ? 1 : 0),
17 edits: t.edits + (c.isEdit ? 1 : 0),
18})
19
20/** Which of the architect's three moments a consult falls at: an inference over this turn so far. */
21export const momentOf = (t: Pick<Turn, 'edits' | 'errorStreak'>): Moment =>
22 t.errorStreak >= 2 ? 'error repeats' : t.edits === 0 ? 'before a plan' : 'before done'
23
24export const startConsult = (a: Architect, c: { id: string; at: number; moment: Moment; via: string }): Architect =>
25 a.consults.some(x => x.id === c.id) ? a : { ...a, consults: [...a.consults, { ...c, endAt: null }].slice(-40) }
26
27/** Ends the open consult (the latest without an end), or the one named. */
28export const endConsult = (a: Architect, at: number, advice: string | null, id?: string): Architect => {
29 const open = [...a.consults].reverse().find(c => c.endAt === null && (id === undefined || c.id === id))
30 return {
31 ...a,
32 consults: a.consults.map(c => (c === open ? { ...c, endAt: at } : c)),
33 lastAdvice: advice ?? a.lastAdvice,
34 }
35}
36
37export const isAdvising = (a: Architect) => a.consults.some(c => c.endAt === null)
38
39/** A one-row timeline of consults across `width` cells: ◆ a consult, ━ while it ran. */
40export const consultTimeline = (a: Architect, now: number, width: number) => {
41 if (a.consults.length === 0 || width < 4) return '─'.repeat(Math.max(0, width))
42 const first = a.consults[0]?.at ?? now
43 const span = Math.max(1, now - first)
44 const cells = Array.from({ length: width }, () => '─')
45 for (const c of a.consults) {
46 const from = Math.min(width - 1, Math.floor(((c.at - first) / span) * (width - 1)))
47 const to = Math.min(width - 1, Math.floor((((c.endAt ?? now) - first) / span) * (width - 1)))
48 for (let i = from + 1; i <= to; i += 1) cells[i] = '━'
49 cells[from] = '◆'
50 }
51 return cells.join('')
52}
53
54/** The words after the timeline, fullest first: count, then how long ago and at what moment, or `advising`. */
55export const archTails = (a: Architect, now: number): string[] => {
56 const count = ` ${a.consults.length} consult${a.consults.length === 1 ? '' : 's'}`
57 if (isAdvising(a)) return [`${count} · advising`, count]
58 const last = a.consults[a.consults.length - 1]
59 if (!last) return [count]
60 const ago = `${count} · last ${fmtDuration(now - (last.endAt ?? last.at))} ago`
61 return [`${ago} · ${last.moment}`, ago, count]
62}
63
64/** The architect row beside its label: the fullest tail that leaves the timeline 8 cells, the timeline taking what remains (up to 24). */
65export const archLine = (a: Architect, now: number, room: number): { timeline: string; tail: string } => {
66 const tails = archTails(a, now)
67 const tail = tails.find(t => room - t.length >= 8) ?? (tails[tails.length - 1] as string)
68 return { timeline: consultTimeline(a, now, Math.max(0, Math.min(24, room - tail.length))), tail }
69}
70
71export const receiptOf = (t: Turn, o: { durationMs: number; agentsSince: number; costNow: number | null; reason: string }): Receipt => ({
72 durationMs: o.durationMs,
73 agents: o.agentsSince,
74 edits: t.edits,
75 errors: t.errors,
76 costDelta: o.costNow !== null && t.costAtStart !== null && o.costNow - t.costAtStart >= 0.005 ? o.costNow - t.costAtStart : null,
77 reason: o.reason,
78})
79
80/**
81 * A subagent's hand-back message: who sent it and the first line of what it said. The report
82 * follows a framing header in the message; without one, the first line after the opening tag.
83 */
84export const handbackOf = (text: string): { from: string; body: string } | null => {
85 const from = /^\s*<agent-message\s+from="([^"]+)"/.exec(text)?.[1]
86 if (!from) return null
87 const afterHeader = text.split(/The report follows:\s*\n/)[1]
88 const rest = afterHeader ?? text.replace(/^\s*<agent-message[^>]*>/, '')
89 const body =
90 rest
91 .split('\n')
92 .map(l => l.trim())
93 .find(l => l && !l.startsWith('[') && !l.startsWith('<') && !l.startsWith('</')) ?? ''
94 return body ? { from, body } : null
95}
96
97/** The advice line a pane shows for a report: its first real line, markdown markers stripped, cut to 160. */
98export const adviceLine = (report: string) => {
99 const first =
100 report
101 .split('\n')
102 .map(l => l.trim())
103 .find(l => l && !l.startsWith('[') && !l.startsWith('<')) ?? ''
104 return shorten(first.replace(/\*\*|__/g, '').replace(/^[#>*\s-]+/, ''), 160)
105}
106
107/** What a pane stores and logs for advice: the report's advice line with credentials masked. Every advice path uses this. */
108export const adviceOf = (report: string) => adviceLine(redact(report))
109hooks/model/config.ts 136 lines1// The plugin's /config options, read leniently, and the colours they choose.
2import type { Layout, Tier } from '../../types'
3import type { PriceRule, TierRule } from './tiers'
4import { TIERS } from './tiers'
5
6export type Panel = 'progress' | 'crew' | 'plan' | 'workflow' | 'servers' | 'architect' | 'gate' | 'log'
7export const PANELS: readonly Panel[] = ['progress', 'crew', 'plan', 'workflow', 'servers', 'architect', 'gate', 'log']
8
9export const DEFAULT_TIERS = 'T0=haiku,T1=sonnet,T2=opus,T3=fable|mythos'
10export const DEFAULT_PRICES = 'fable|mythos=10/50/0.25,opus=4/20/0.2,sonnet=2/10/0.2,haiku=1/5/0.1'
11
12export type Palette = 'theme' | 'pastel'
13export type Colors = Record<
14 'main' | 'gate' | 'cleared' | 'arch' | 'amber' | 'warn' | 'dim' | 'faint' | 'text' | 'ok' | 'run' | 't0' | 't1' | 't2' | 't3',
15 string
16>
17
18/** `theme` names the person's theme colours (light, dark, colour-blind all read); `pastel` is fixed hex for dark terminals. */
19export const PALETTES: Record<Palette, Colors> = {
20 theme: {
21 main: 'claude',
22 gate: 'success',
23 cleared: 'permission',
24 arch: 'merged',
25 amber: 'warning',
26 warn: 'error',
27 dim: 'inactive',
28 faint: 'subtle',
29 text: 'text',
30 ok: 'success',
31 run: 'suggestion',
32 t0: 'permission',
33 t1: 'suggestion',
34 t2: 'merged',
35 t3: 'claude',
36 },
37 pastel: {
38 main: '#7dd3fc',
39 gate: '#86efac',
40 cleared: '#5eead4',
41 arch: '#c4b5fd',
42 amber: '#fcd34d',
43 warn: '#fca5a5',
44 dim: '#6b7280',
45 faint: '#3f4654',
46 text: '#e5e7eb',
47 ok: '#3fb950',
48 run: '#58a6ff',
49 t0: '#7ee0c3',
50 t1: '#79c0ff',
51 t2: '#d2a8ff',
52 t3: '#f2cc60',
53 },
54}
55
56export type Config = {
57 tiers: TierRule[]
58 prices: PriceRule[]
59 contextAmber: number
60 contextRed: number
61 panels: Panel[]
62 layout: Layout
63 maxBoxes: number
64 architect: RegExp
65 architectLabel: string
66 gateLabel: string
67 palette: Palette
68 openOnStart: boolean
69 statusLine: boolean
70}
71
72const safeRegExp = (source: string, fallback: string) => {
73 try {
74 return new RegExp(source || fallback, 'i')
75 } catch {
76 return new RegExp(fallback, 'i')
77 }
78}
79
80const pairs = (s: string) =>
81 s
82 .split(',')
83 .map(p => p.trim())
84 .filter(p => p.includes('='))
85 .map(p => [p.slice(0, p.indexOf('=')).trim(), p.slice(p.indexOf('=') + 1).trim()] as const)
86 .filter(([k, v]) => k !== '' && v !== '')
87
88export const parseTiers = (s: string): TierRule[] =>
89 pairs(s).flatMap(([t, re]) => ((TIERS as readonly string[]).includes(t) ? [{ tier: t as Tier, re: safeRegExp(re, '$^') }] : []))
90
91export const parsePrices = (s: string): PriceRule[] =>
92 pairs(s).flatMap(([re, nums]) => {
93 const n = nums.split('/').map(x => (x.trim() === '' ? Number.NaN : Number(x)))
94 const [i, o, r] = n
95 return n.length === 3 && n.every(x => Number.isFinite(x) && x >= 0)
96 ? [{ re: safeRegExp(re, '$^'), price: { in: i as number, out: o as number, cacheRead: r as number } }]
97 : []
98 })
99
100export const parseConfig = (o: Readonly<Record<string, unknown>>): Config => {
101 const str = (k: string, d: string) => (typeof o[k] === 'string' && o[k] !== '' ? (o[k] as string) : d)
102 const bool = (k: string, d: boolean) => (typeof o[k] === 'boolean' ? (o[k] as boolean) : d)
103 const num = (k: string, d: number) => (typeof o[k] === 'number' && Number.isFinite(o[k]) ? (o[k] as number) : d)
104 const panels = str('panels', PANELS.join(','))
105 .split(',')
106 .map(s => s.trim())
107 .filter((p): p is Panel => (PANELS as readonly string[]).includes(p))
108 const amber = num('contextAmber', 70)
109 const red = num('contextRed', 80)
110 const okThresholds = amber > 0 && amber < red && red <= 100
111 const layout = str('layout', 'auto')
112 const tiers = parseTiers(str('tiers', DEFAULT_TIERS))
113 return {
114 tiers: tiers.length > 0 ? tiers : parseTiers(DEFAULT_TIERS),
115 prices: parsePrices(str('prices', DEFAULT_PRICES)),
116 contextAmber: okThresholds ? amber : 70,
117 contextRed: okThresholds ? red : 80,
118 panels: panels.length > 0 ? [...new Set(panels)] : [...PANELS],
119 layout: layout === 'hybrid' || layout === 'mini' ? layout : 'auto',
120 maxBoxes: Math.min(6, Math.max(1, Math.round(num('maxBoxes', 3)))),
121 architect: safeRegExp(str('architectPattern', ''), 'advisor|architect'),
122 architectLabel: str('architectLabel', 'ARCHITECT'),
123 gateLabel: str('gateLabel', 'GATE'),
124 palette: str('palette', 'theme') === 'pastel' ? 'pastel' : 'theme',
125 openOnStart: bool('openOnStart', true),
126 statusLine: bool('statusLine', true),
127 }
128}
129
130export type CtxLevel = 'unknown' | 'ok' | 'amber' | 'red'
131export const contextLevel = (pct: number | null, cfg: Pick<Config, 'contextAmber' | 'contextRed'>): CtxLevel =>
132 pct === null ? 'unknown' : pct >= cfg.contextRed ? 'red' : pct >= cfg.contextAmber ? 'amber' : 'ok'
133
134export const tierColor = (C: Colors, tier: Tier | null) =>
135 tier === 'T0' ? C.t0 : tier === 'T1' ? C.t1 : tier === 'T2' ? C.t2 : tier === 'T3' ? C.t3 : C.dim
136hooks/model/crew.ts 101 lines1// Workers: the subagents the orchestrator dispatched. Pure reducers over the worker list.
2import type { ToolNote, Worker, WorkerStatus } from '../../types'
3import { kTokens, normalize, redact, shorten } from './util'
4
5export const DEFAULT_WORKER: Worker = {
6 id: '',
7 n: 0,
8 type: 'agent',
9 description: '',
10 model: '',
11 modelSource: 'definition',
12 status: 'running',
13 spawnedAt: 0,
14 endedAt: null,
15 steps: 0,
16 ctx: 0,
17 out: 0,
18 tokIn: 0,
19 tokCacheRead: 0,
20 tokCacheWrite: 0,
21 lastStop: null,
22 tools: [],
23 answer: '',
24}
25export const MAX_WORKERS = 24
26
27export const normalizeWorker = (stored: unknown): Worker => normalize(DEFAULT_WORKER, stored)
28export const nextN = (list: Worker[]) => list.reduce((m, w) => Math.max(m, w.n), 0) + 1
29/** Over the cap, drop the oldest worker that isn't running (or the oldest of all, when every one is): a running one keeps its steps and its completion. */
30const cap = (list: Worker[]) => {
31 if (list.length <= MAX_WORKERS) return list
32 const drop = list.find(w => w.status !== 'running') ?? list[0]
33 return list.filter(w => w !== drop)
34}
35
36export const addWorker = (list: Worker[], w: Worker) => cap([...list.filter(x => x.id !== w.id), w])
37export const updateWorker = (list: Worker[], id: string, f: (w: Worker) => Worker) => list.map(w => (w.id === id ? f(w) : w))
38
39type StepUsage =
40 | { input_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; output_tokens?: number }
41 | null
42 | undefined
43
44export const stepWorker = (w: Worker, s: { model: string; usage: StepUsage; stopReason: string | null }): Worker => {
45 const u = s.usage ?? {}
46 const tokIn = u.input_tokens ?? 0
47 const read = u.cache_read_input_tokens ?? 0
48 const write = u.cache_creation_input_tokens ?? 0
49 const ctx = tokIn + read + write
50 return {
51 ...w,
52 model: w.model || s.model,
53 steps: w.steps + 1,
54 ctx: ctx > 0 ? ctx : w.ctx,
55 out: w.out + (u.output_tokens ?? 0),
56 tokIn: w.tokIn + tokIn,
57 tokCacheRead: w.tokCacheRead + read,
58 tokCacheWrite: w.tokCacheWrite + write,
59 lastStop: s.stopReason,
60 }
61}
62
63export const noteWorkerTool = (w: Worker, n: ToolNote): Worker => ({ ...w, tools: [...w.tools, n].slice(-3) })
64
65const statusOf = (reason: string): WorkerStatus => (reason === 'answer' ? 'done' : reason === 'aborted' ? 'stopped' : 'failed')
66
67export const completeWorker = (w: Worker, o: { reason: string; at: number; answer: string }): Worker => ({
68 ...w,
69 status: statusOf(o.reason),
70 endedAt: o.at,
71 answer: shorten(redact(o.answer), 400),
72})
73
74/** A run on the axis from `start` to `now` in `width` cells: cells before it, its bar (≥1), cells after. */
75export const timeBar = (w: Pick<Worker, 'spawnedAt' | 'endedAt'>, start: number, now: number, width: number) => {
76 const cells = Math.max(1, width)
77 const span = Math.max(1, now - start)
78 const from = Math.min(cells - 1, Math.max(0, Math.floor(((Math.max(w.spawnedAt, start) - start) / span) * cells)))
79 const to = Math.min(cells, Math.max(from + 1, Math.ceil((((w.endedAt ?? now) - start) / span) * cells)))
80 return { before: from, bar: to - from, after: cells - to }
81}
82
83export const workersStart = (ws: Worker[], now: number) => Math.min(now, ...ws.map(w => w.spawnedAt).filter(n => n > 0))
84
85/** Worker boxes read left to right in spawn order (W1 W2 W3), whatever their status. */
86export const boxOrder = (ws: Worker[]) => [...ws].sort((a, b) => a.n - b.n)
87
88/** Folded rows: running workers first, then finished ones, each newest first: a cut to the first rows keeps the newest. */
89export const drawOrder = (ws: Worker[]) => [
90 ...ws.filter(w => w.status === 'running').reverse(),
91 ...ws.filter(w => w.status !== 'running').reverse(),
92]
93
94/** The tail of a worker box's status line that fits `room` cells: ` 18k · 7st`, else ` · 7st`, else ` 7st`. Steps always stay. */
95export const boxStats = (o: Pick<Worker, 'out' | 'steps'>, room: number) => {
96 const steps = `${o.steps}st`
97 const both = ` ${kTokens(o.out)} · ${steps}`
98 const stepsOnly = ` · ${steps}`
99 return both.length <= room ? both : stepsOnly.length <= room ? stepsOnly : ` ${steps}`
100}
101hooks/model/gate.ts 111 lines1// Ported from Flightdeck v0.4.0 (MIT, Stephen Casella), hooks/core.ts.
2// Pure: nothing here touches `$`.
3import type { Bucket, Check, Gate, Tally } from '../../types'
4import { listOf, normalize } from './util'
5
6const ZERO: Tally = { rule: 0, ask: 0, cleared: 0, deny: 0 }
7export const DEFAULT_GATE: Gate = { recent: [], totals: { file: ZERO, shell: ZERO, other: ZERO } }
8
9export const normalizeGate = (stored: unknown): Gate => {
10 const g = normalize(DEFAULT_GATE, stored)
11 const totals = normalize(DEFAULT_GATE.totals, g.totals)
12 return {
13 recent: listOf<Check>(g.recent),
14 totals: { file: normalize(ZERO, totals.file), shell: normalize(ZERO, totals.shell), other: normalize(ZERO, totals.other) },
15 }
16}
17
18/** Keeps the last `max` checks, but never drops a pending ask: its settle must still find it. */
19export const trimRecent = (list: Check[], max: number): Check[] => {
20 let extra = list.length - max
21 if (extra <= 0) return list
22 const out: Check[] = []
23 for (const c of list) {
24 if (extra > 0 && c.verdict !== 'ask') {
25 extra -= 1
26 continue
27 }
28 out.push(c)
29 }
30 return out.slice(-max * 2)
31}
32
33export const recordCheck = (g: Gate, c: Check): Gate => {
34 const t = g.totals[c.bucket]
35 return {
36 recent: trimRecent([...g.recent, c], 80),
37 totals: { ...g.totals, [c.bucket]: { ...t, [c.verdict]: t[c.verdict] + 1 } },
38 }
39}
40
41/** An `ask` settled by the call that followed it: it ran (cleared) or was refused (deny). */
42export const settleCheck = (g: Gate, id: string, didRun: boolean): Gate => {
43 const c = g.recent.find(r => r.id === id && r.verdict === 'ask')
44 if (!c) return g
45 const verdict = didRun ? 'cleared' : 'deny'
46 const t = g.totals[c.bucket]
47 return {
48 recent: g.recent.map(r => (r === c ? { ...r, verdict } : r)),
49 totals: { ...g.totals, [c.bucket]: { ...t, ask: Math.max(0, t.ask - 1), [verdict]: t[verdict] + 1 } },
50 }
51}
52
53export const gateSummary = (g: Gate) => {
54 const all = (['file', 'shell', 'other'] as const).reduce(
55 (s, k) => ({
56 rule: s.rule + g.totals[k].rule,
57 ask: s.ask + g.totals[k].ask,
58 cleared: s.cleared + g.totals[k].cleared,
59 deny: s.deny + g.totals[k].deny,
60 }),
61 { ...ZERO },
62 )
63 return { ...all, total: all.rule + all.ask + all.cleared + all.deny }
64}
65
66export const GATE_BUCKETS: readonly Bucket[] = ['file', 'shell', 'other']
67
68/** Cells the three drill-down buttons take. Each draws `f: file`, 3 more than its label; one cell between them; `▾` marks the open one. */
69export const gateButtonCells = (open: Bucket | null) =>
70 GATE_BUCKETS.reduce((n, b) => n + 3 + b.length + (open === b ? 1 : 0), GATE_BUCKETS.length - 1)
71
72/** The totals as three parts, so the pane can colour pending and denied apart. */
73export type GateTotals = { count: string; ask: string; deny: string }
74export const totalsCells = (t: GateTotals) => t.count.length + t.ask.length + t.deny.length
75
76/**
77 * The totals from most said to least: `31 checks · 2 pending · 1✗`, then `31·2?·1✗`, then one flag
78 * (pending before denied), then the count alone.
79 */
80export const gateTotalsVariants = (s: Pick<ReturnType<typeof gateSummary>, 'total' | 'ask' | 'deny'>): GateTotals[] => {
81 const count = ` ${s.total}`
82 const ask = s.ask > 0 ? `·${s.ask}?` : ''
83 const deny = s.deny > 0 ? `·${s.deny}✗` : ''
84 return [
85 { count: `${count} checks`, ask: s.ask > 0 ? ` · ${s.ask} pending` : '', deny: s.deny > 0 ? ` · ${s.deny}✗` : '' },
86 { count, ask, deny },
87 { count, ask, deny: ask ? '' : deny },
88 { count, ask: '', deny: '' },
89 ]
90}
91
92/**
93 * How the gate row spends its width: label, strip cells, totals, a gap, then the three buttons.
94 * The totals take their room first: the fullest wording that fits with no cells at all. Whatever is
95 * left becomes strip cells, but only from 6 up; below that the cells are dropped.
96 */
97export const gateLayout = (o: {
98 width: number
99 labelW: number
100 summary: Pick<ReturnType<typeof gateSummary>, 'total' | 'ask' | 'deny'>
101 recent: number
102 open: Bucket | null
103}): { cells: number; totals: GateTotals; gap: number } => {
104 const gap = o.width < 60 ? 1 : 2
105 const room = (t: GateTotals) => o.width - o.labelW - gap - gateButtonCells(o.open) - totalsCells(t)
106 const variants = gateTotalsVariants(o.summary)
107 const totals = variants.find(t => room(t) >= 0) ?? (variants[variants.length - 1] as GateTotals)
108 const left = room(totals)
109 return { cells: left >= 6 ? Math.min(left, o.recent) : 0, totals, gap }
110}
111hooks/model/log.ts 61 lines1// Ported from Flightdeck v0.4.0 (MIT, Stephen Casella), hooks/core.ts.
2// Pure: nothing here touches `$`.
3import type { LogLine, Receipt } from '../../types'
4import { fmtDuration, fmtUsd, listOf, plural, redact, shorten } from './util'
5
6export const LOG_MAX = 200
7export const pushLog = (list: LogLine[], line: LogLine): LogLine[] => [...list, line].slice(-LOG_MAX)
8
9export const normalizeLog = (stored: unknown): LogLine[] =>
10 listOf<Record<string, unknown>>(stored).map(l => ({
11 at: typeof l.at === 'number' ? l.at : 0,
12 who: String(l.who ?? ''),
13 text: String(l.text ?? ''),
14 agentId: typeof l.agentId === 'string' ? l.agentId : null,
15 kind: l.kind === 'error' || l.kind === 'consult' || l.kind === 'done' ? l.kind : 'info',
16 }))
17
18/**
19 * What a turn's opening text was, for the log: the person's words, or for a turn the engine
20 * opened with a tagged message (a subagent's hand-back, a task notification), that message's kind.
21 */
22export const promptLine = (text: string): { who: string; text: string } => {
23 const tag = /^\s*<([a-z][\w-]*)/i.exec(text)?.[1]
24 if (!tag) return { who: 'you', text: shorten(redact(text), 70) }
25 const from = /\bfrom="([^"]+)"/.exec(text)?.[1]
26 return { who: 'engine', text: shorten(`${tag.replace(/[-_]/g, ' ')}${from ? ` from ${from.slice(0, 8)}` : ''}`, 70) }
27}
28
29/**
30 * The receipt's wording, fullest first, as `body` and `cost`. Narrower wordings drop the cost, then the
31 * words `last turn`, then spell the counts short, so the errors count is never the part that is cut.
32 */
33export const receiptVariants = (r: Receipt): { body: string; cost: string }[] => {
34 const dur = fmtDuration(r.durationMs)
35 const cost = r.costDelta !== null ? ` · +${fmtUsd(r.costDelta)}` : ''
36 const counts = `${plural(r.agents, 'worker')} · ${plural(r.edits, 'edit')} · ${plural(r.errors, 'error')}`
37 return [
38 { body: `last turn ${dur} · ${counts}`, cost },
39 { body: `last turn ${dur} · ${counts}`, cost: '' },
40 { body: `${dur} · ${counts}`, cost: '' },
41 { body: `${dur} · ${r.agents}w · ${r.edits}e · ${r.errors}err`, cost: '' },
42 ]
43}
44
45/** The first wording that fits `room` cells (the mark `✓ ` takes 2 of them); the shortest when none does. */
46export const receiptFit = (r: Receipt, room: number) => {
47 const all = receiptVariants(r)
48 return all.find(v => v.body.length + v.cost.length <= room - 2) ?? (all[all.length - 1] as { body: string; cost: string })
49}
50
51const reasonOf = (err: unknown) => (err instanceof Error ? err.message : String(err))
52
53/** The log row a failed hook leaves: which hook and why, with credentials masked and the reason cut to 80. */
54export const failureLine = (hook: string, err: unknown) => `${hook} failed: ${shorten(redact(reasonOf(err)), 80)}`
55
56/** The one row a pane that cannot be drawn shows: the reason masked and cut so the row fits the pane (37 cells at least). */
57export const renderFailure = (err: unknown, columns: number) => {
58 const lead = 'conductor-mod render failed: '
59 return `${lead}${shorten(redact(reasonOf(err)), Math.max(lead.length + 8, columns) - lead.length)}`
60}
61hooks/model/plan.ts 109 lines1// The session's task list, read from TaskCreate / TaskUpdate (or TodoWrite) calls: progress, ETA, now and next.
2import type { PlanTask, TaskStatus } from '../../types'
3import { isObject, redact, shorten } from './util'
4
5export type TaskEvent =
6 | { kind: 'create'; id: string; subject: string; at: number }
7 | { kind: 'status'; id: string; status: TaskStatus; at: number }
8 | { kind: 'subject'; id: string; subject: string }
9 | { kind: 'replace'; todos: { subject: string; status: TaskStatus }[]; at: number }
10
11/** A subject as stored: credentials masked first, then cut to fit the strip. */
12const clean = (s: string) => shorten(redact(s), 80)
13
14const STATUSES: readonly TaskStatus[] = ['pending', 'in_progress', 'completed', 'deleted']
15const isStatus = (v: unknown): v is TaskStatus => typeof v === 'string' && (STATUSES as readonly string[]).includes(v)
16/** The new task's id in TaskCreate's result text, e.g. `Task #3 created successfully: …`. */
17const TASK_ID = /Task #(\d+)/
18
19const withStatus = (t: PlanTask, status: TaskStatus, at: number): PlanTask => {
20 if (status === t.status) return t
21 if (status === 'in_progress') return { ...t, status, startedAt: at, completedAt: null }
22 if (status === 'completed') return { ...t, status, completedAt: at }
23 if (status === 'pending') return { ...t, status, startedAt: null, completedAt: null }
24 return { ...t, status }
25}
26
27const fresh = (id: string, subject: string, at: number): PlanTask => ({
28 id,
29 subject,
30 status: 'pending',
31 createdAt: at,
32 startedAt: null,
33 completedAt: null,
34})
35
36export const applyTaskEvent = (tasks: PlanTask[], e: TaskEvent): PlanTask[] => {
37 switch (e.kind) {
38 case 'create':
39 return tasks.some(t => t.id === e.id) ? tasks : [...tasks, fresh(e.id, e.subject, e.at)]
40 case 'subject':
41 return tasks.map(t => (t.id === e.id ? { ...t, subject: e.subject } : t))
42 case 'status':
43 return tasks.map(t => (t.id === e.id ? withStatus(t, e.status, e.at) : t))
44 case 'replace': {
45 const todos = e.todos.map(td => {
46 const id = `todo:${td.subject}`
47 return withStatus(tasks.find(t => t.id === id) ?? fresh(id, td.subject, e.at), td.status, e.at)
48 })
49 return [...tasks.filter(t => !t.id.startsWith('todo:')), ...todos]
50 }
51 }
52}
53
54export const taskFromCreate = (input: Record<string, unknown>, resultText: string, at: number): TaskEvent | null => {
55 const subject = typeof input.subject === 'string' ? input.subject.trim() : ''
56 const id = TASK_ID.exec(resultText)?.[1]
57 return subject && id ? { kind: 'create', id, subject: clean(subject), at } : null
58}
59
60export const taskFromUpdate = (input: Record<string, unknown>, at: number): TaskEvent[] => {
61 const raw = input.taskId
62 const id = typeof raw === 'string' || typeof raw === 'number' ? String(raw) : ''
63 if (!id) return []
64 const out: TaskEvent[] = []
65 if (typeof input.subject === 'string' && input.subject.trim()) out.push({ kind: 'subject', id, subject: clean(input.subject) })
66 if (isStatus(input.status)) out.push({ kind: 'status', id, status: input.status, at })
67 return out
68}
69
70export const taskFromTodoWrite = (input: Record<string, unknown>, at: number): TaskEvent | null => {
71 const raw = input.todos
72 if (!Array.isArray(raw)) return null
73 const todos = raw
74 .filter(isObject)
75 .filter(t => typeof t.content === 'string' && t.content.trim() !== '' && isStatus(t.status))
76 .map(t => ({ subject: clean(String(t.content)), status: t.status as TaskStatus }))
77 // A non-empty list that yields nothing is malformed, not "clear the list"; only a literal [] clears.
78 return todos.length === 0 && raw.length > 0 ? null : { kind: 'replace', todos, at }
79}
80
81export const liveTasks = (tasks: PlanTask[]) => tasks.filter(t => t.status !== 'deleted')
82
83export const taskProgress = (tasks: PlanTask[]) => {
84 const live = liveTasks(tasks)
85 return { done: live.filter(t => t.status === 'completed').length, total: live.length }
86}
87
88export const meanTaskMs = (tasks: PlanTask[]) => {
89 const d = liveTasks(tasks)
90 .filter(t => t.status === 'completed' && t.completedAt !== null)
91 .map(t => (t.completedAt as number) - (t.startedAt ?? t.createdAt))
92 .filter(ms => ms >= 0)
93 return d.length > 0 ? d.reduce((s, ms) => s + ms, 0) / d.length : null
94}
95
96/** What is left: a full mean per item not started, the unspent part of the mean for one under way. */
97export const remainingMs = (items: { startedAt: number | null }[], mean: number | null, now: number) =>
98 mean === null ? null : items.reduce((s, t) => s + (t.startedAt !== null ? Math.max(0, mean - Math.max(0, now - t.startedAt)) : mean), 0)
99
100export const taskEta = (tasks: PlanTask[], now: number) =>
101 remainingMs(
102 liveTasks(tasks).filter(t => t.status !== 'completed'),
103 meanTaskMs(tasks),
104 now,
105 )
106
107export const nextTask = (tasks: PlanTask[]) => liveTasks(tasks).find(t => t.status === 'pending')?.subject ?? null
108export const currentTask = (tasks: PlanTask[]) => liveTasks(tasks).find(t => t.status === 'in_progress')?.subject ?? null
109hooks/model/progress.ts 75 lines1// Which progress to show (tasks, then workflow agents, then workers), now/next, and the status line.
2import type { Main, PlanTask, Worker, WorkflowRun } from '../../types'
3import { currentTask, meanTaskMs, nextTask, remainingMs, taskEta, taskProgress } from './plan'
4import { fmtEta } from './util'
5import { runProgress } from './workflow'
6
7export type Progress = {
8 source: 'tasks' | 'agents' | 'workers'
9 done: number
10 total: number
11 pct: number
12 etaMs: number | null
13 meanMs: number | null
14}
15
16const pctOf = (done: number, total: number) => (total > 0 ? Math.round((done / total) * 100) : 0)
17const mean = (ms: number[]) => (ms.length > 0 ? ms.reduce((s, x) => s + x, 0) / ms.length : null)
18
19/** Everything counted is done, so there is nothing left to estimate: the row says `done` and the status line drops the ETA. */
20export const isComplete = (p: Progress) => p.total > 0 && p.done >= p.total && p.pct >= 100
21
22export const progressOf = (o: { tasks: PlanTask[]; run: WorkflowRun | null; workers: Worker[]; now: number }): Progress | null => {
23 const tp = taskProgress(o.tasks)
24 if (tp.total > 0)
25 return { source: 'tasks', ...tp, pct: pctOf(tp.done, tp.total), etaMs: taskEta(o.tasks, o.now), meanMs: meanTaskMs(o.tasks) }
26 if (o.run && o.run.agents.length > 0) {
27 // Workflow agents report no phase, so what is counted is the agents seen so far: the total grows as they appear. While the run
28 // is open that is no denominator to trust, so it never reads 100% and has no ETA; the run's own end makes it whole.
29 const rp = runProgress(o.run)
30 const pct = pctOf(rp.done, rp.total)
31 return {
32 source: 'agents',
33 done: rp.done,
34 total: rp.total,
35 pct: o.run.endedAt === null ? Math.min(99, pct) : pct,
36 etaMs: null,
37 meanMs: mean(o.run.agents.filter(a => a.isDone).map(a => a.lastAt - a.firstAt)),
38 }
39 }
40 if (o.workers.length > 0) {
41 const finished = o.workers.filter(w => w.status !== 'running')
42 const m = mean(finished.filter(w => w.endedAt !== null).map(w => (w.endedAt as number) - w.spawnedAt))
43 const open = o.workers.filter(w => w.status === 'running').map(w => ({ startedAt: w.spawnedAt }))
44 return {
45 source: 'workers',
46 done: finished.length,
47 total: o.workers.length,
48 pct: pctOf(finished.length, o.workers.length),
49 etaMs: remainingMs(open, m, o.now),
50 meanMs: m,
51 }
52 }
53 return null
54}
55
56export const nowNext = (o: { workers: Worker[]; tasks: PlanTask[]; main: Main }) => {
57 const busy = [...o.workers].reverse().find(w => w.status === 'running')
58 const now = busy
59 ? `W${busy.n} ${busy.description || busy.type}`
60 : (currentTask(o.tasks) ?? (o.main.isRunning ? o.main.activity || 'thinking' : null))
61 return { now, next: nextTask(o.tasks) }
62}
63
64export const statusText = (o: { progress: Progress | null; ctxPct: number | null; running: number; flagged: number; servers?: number }) => {
65 const parts = [
66 o.progress ? `${o.progress.pct}%` : null,
67 o.progress?.etaMs != null && !isComplete(o.progress) ? fmtEta(o.progress.etaMs) : null,
68 o.ctxPct !== null ? `ctx ${Math.round(o.ctxPct)}%` : null,
69 o.running > 0 ? `${o.running} running` : null,
70 o.flagged > 0 ? `${o.flagged}⚠` : null,
71 o.servers ? `${o.servers} srv` : null,
72 ].filter(Boolean)
73 return parts.length > 0 ? parts.join(' · ') : undefined
74}
75hooks/model/servers.ts 202 lines1// Local servers: the ones this session started in the background, and the localhost ports it used.
2// Everything is read from session events (tool calls and task notices); nothing here asks the machine what is listening.
3import type { Server } from '../../types'
4import { fitSegments, fmtDuration, isObject, redact, shorten } from './util'
5
6export const MAX_SERVERS = 12
7
8const SERVER_CMD =
9 /\b(dev|serve|server|start|preview|vite|next|nuxt|astro|storybook|uvicorn|gunicorn|flask|runserver|http\.server|webpack-dev-server|wrangler\s+dev)\b|\brails\s+s\b|\bphp\s+-S\b|\bdocker\s+compose\s+up\b|--port\b|\bPORT=\d+/i
10const NOT_SERVER = /\b(test|build|lint|install|tsc|check)\b/i
11/** A port the server part of a command asks for, in order of trust: a flag or `PORT=`, then `http.server N`, then a bare `host:port` (`php -S localhost:8000`). */
12const PORT_FLAG = /(?:--port[=\s]+|\s-p\s+|\bPORT=)(\d{2,5})\b/
13const PORT_HTTP_SERVER = /\bhttp\.server\s+(\d{2,5})\b/
14const PORT_HOST = /\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d{2,5})\b/
15/** A `NAME=value` token: its value may name a service the server calls (`VITE_API=http://localhost:8000`), never the server's own port. */
16const ENV_ASSIGN = /\b[A-Za-z_]\w*=\S*/g
17/** Any sign that a call's command or URL names this machine, so the call may use a server. */
18const LOCAL_HINT = /localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]/i
19/** `a && b`, `a || b`, `a ; b`, `a | b`: each part is judged alone. */
20const SEGMENT_SPLIT = /&&|\|\||;|\|/
21const LOCAL = /\b(?:https?:\/\/)?(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]):(\d{2,5})\b/gi
22/**
23 * The background job id in a Bash result, e.g. `Command running in background with ID: b7k2x9` (inferred: the spike did not observe one).
24 * 4 to 32 id characters; a longer token is not an id.
25 */
26const JOB_ID = /\bID:\s*([A-Za-z0-9_-]{4,32})(?![A-Za-z0-9_-])/
27/** A notice with no `<status>` is judged on all its text. */
28const FAILED = /\bfail(ed|ure)?\b|\berror\b|exit code [1-9]/i
29/** A notice with a `<status>` (inferred) is judged on that alone: `failed`, `error`, or a non-zero exit code. */
30const STATUS_TAG = /<status>([\s\S]*?)<\/status>/i
31const STATUS_FAILED = /\b(?:fail(?:ed|ure)?|error)\b|\bexit(?:ed)?[\s_-]*(?:with[\s_-]*)?(?:code)?[\s:=_-]*[1-9]\d*\b/i
32/** The tools that stop a background job, taking its id as `bash_id`, `shell_id` or `task_id` (inferred). */
33const STOP_TOOLS = new Set(['KillShell', 'KillBash', 'TaskStop'])
34/** What a job id is made of; a notice names a job only when these do not run on either side of it. */
35const ID_CHARS = 'A-Za-z0-9_-'
36
37const isServerPart = (seg: string) => SERVER_CMD.test(seg) && !NOT_SERVER.test(seg)
38
39/** A server command: some part of a chain (`npm install && npm run dev`) looks like one and is not a test, build or the like. */
40export const isServerCommand = (cmd: string) => cmd.split(SEGMENT_SPLIT).some(isServerPart)
41
42/**
43 * The port the server asks for, read from the part of the chain that is the server and nowhere else: not `curl localhost:8000 &&`
44 * before it, and not a `NAME=value` URL on it. None named: null, so the first local port the session uses can be adopted.
45 */
46export const portOf = (cmd: string): number | null => {
47 const seg = cmd.split(SEGMENT_SPLIT).find(isServerPart) ?? ''
48 const m = PORT_FLAG.exec(seg) ?? PORT_HTTP_SERVER.exec(seg) ?? PORT_HOST.exec(seg.replace(ENV_ASSIGN, ' '))
49 const n = Number(m?.[1])
50 return Number.isInteger(n) && n > 0 && n < 65536 ? n : null
51}
52
53export const localPorts = (text: string): number[] => [
54 ...new Set([...text.matchAll(LOCAL)].map(m => Number(m[1])).filter(n => n > 0 && n < 65536)),
55]
56
57export const jobIdOf = (text: string) => JOB_ID.exec(text)?.[1] ?? null
58
59const str = (v: unknown) => (typeof v === 'string' ? v : '')
60const jobOfInput = (input: Record<string, unknown>) =>
61 [input.bash_id, input.shell_id, input.task_id, input.id].map(str).find(Boolean) ?? null
62
63/** Over the cap, drop the oldest server that isn't live (or the oldest of all). */
64const cap = (list: Server[]) => {
65 if (list.length <= MAX_SERVERS) return list
66 const drop = list.find(s => s.status !== 'live') ?? list[0]
67 return list.filter(s => s !== drop)
68}
69
70const hasFailed = (text: string) => {
71 const status = STATUS_TAG.exec(text)?.[1]
72 return status === undefined ? FAILED.test(text) : STATUS_FAILED.test(status)
73}
74
75const end = (s: Server, at: number, text: string): Server => ({ ...s, status: hasFailed(text) ? 'failed' : 'stopped', endedAt: at })
76
77export const liveServers = (list: Server[]) => list.filter(s => s.status === 'live')
78
79const later = (a: number | null, b: number | null) => (a === null ? b : b === null ? a : Math.max(a, b))
80
81/**
82 * Puts `s` in the list, in place when it is there. A live server with a port takes over the `seen` rows on that port, summing their
83 * uses and keeping the later last use: one server is one row, whichever of its start and its first probe came first.
84 */
85const settle = (list: Server[], s: Server): Server[] => {
86 const seen = s.status === 'live' && s.port !== null ? list.filter(x => x.status === 'seen' && x.port === s.port && x.id !== s.id) : []
87 const merged: Server = {
88 ...s,
89 uses: seen.reduce((n, x) => n + x.uses, s.uses),
90 lastUsedAt: seen.reduce((t, x) => later(t, x.lastUsedAt), s.lastUsedAt),
91 }
92 const rest = list.filter(x => !seen.includes(x))
93 return rest.some(x => x.id === s.id) ? rest.map(x => (x.id === s.id ? merged : x)) : [...rest, merged]
94}
95
96/** True when a call could change the list: a background Bash call, a stop, a call about a job, or a command or URL that names this machine. */
97export const mayAffectServers = (tool: string, input: Record<string, unknown>): boolean =>
98 (tool === 'Bash' && input.run_in_background === true) ||
99 STOP_TOOLS.has(tool) ||
100 jobOfInput(input) !== null ||
101 LOCAL_HINT.test(`${str(input.command)} ${str(input.url)}`)
102
103export type CallFacts = {
104 tool: string
105 input: Record<string, unknown>
106 resultText: string
107 didRun: boolean
108 at: number
109 by: string
110 callId: string
111}
112
113/**
114 * One tool call's effect on the server list: a start, a stop, a port learned from output, or ports used.
115 * Only the redacted command (cut to 60), ports and job ids are kept from a call.
116 */
117export const serversAfterCall = (list: Server[], c: CallFacts): Server[] => {
118 if (!c.didRun || !isObject(c.input)) return list
119 const command = str(c.input.command)
120 if (c.tool === 'Bash' && c.input.run_in_background === true) {
121 if (!isServerCommand(command)) return list
122 const s: Server = {
123 id: c.callId,
124 jobId: jobIdOf(c.resultText),
125 port: portOf(command),
126 command: shorten(redact(command), 60),
127 status: 'live',
128 startedAt: c.at,
129 endedAt: null,
130 lastUsedAt: null,
131 uses: 0,
132 by: c.by,
133 }
134 return cap(settle(list, s))
135 }
136 const job = jobOfInput(c.input)
137 if (job && STOP_TOOLS.has(c.tool)) return list.map(s => (s.jobId === job && s.status === 'live' ? end(s, c.at, '') : s))
138 if (job) {
139 const port = localPorts(c.resultText)[0]
140 const blank = port ? list.find(s => s.jobId === job && s.port === null) : undefined
141 return blank ? settle(list, { ...blank, port: port as number }) : list
142 }
143 const target = command || str(c.input.url)
144 if (!target) return list
145 const used = (s: Server): Server => ({ ...s, uses: s.uses + 1, lastUsedAt: c.at })
146 return localPorts(target).reduce<Server[]>((acc, port) => {
147 const live = acc.find(s => s.port === port && s.status === 'live')
148 if (live) return acc.map(s => (s === live ? used(s) : s))
149 // (inferred) With exactly one live server whose port is not known, the first local port the session uses is taken to be its own.
150 const blank = liveServers(acc).filter(s => s.port === null)
151 const [only] = blank
152 if (only && blank.length === 1) return settle(acc, { ...used(only), port })
153 const known = acc.find(s => s.port === port && s.status === 'seen')
154 if (known) return acc.map(s => (s === known ? used(s) : s))
155 const seen: Server = {
156 id: `port:${port}`,
157 jobId: null,
158 port,
159 command: '',
160 status: 'seen',
161 startedAt: c.at,
162 endedAt: null,
163 lastUsedAt: c.at,
164 uses: 1,
165 by: c.by,
166 }
167 return cap([...acc.filter(s => s.id !== seen.id), seen])
168 }, list)
169}
170
171const escapeRe = (s: string) => s.replace(/[\\^$.*+?()[\]{}|-]/g, '\\$&')
172
173/** True when `text` names `jobId` as a whole token (`b10` is not `b1`): no id character runs on to either side of it (inferred). */
174const namesJob = (text: string, jobId: string) => new RegExp(`(?:^|[^${ID_CHARS}])${escapeRe(jobId)}(?![${ID_CHARS}])`).test(text)
175
176const names = (s: Server, text: string) => s.status === 'live' && s.jobId !== null && namesJob(text, s.jobId)
177
178/** A background-task notice ends the live server whose job id it names, as `stopped` or `failed` (inferred: the notice's wording was not observed). */
179export const serversAfterNotice = (list: Server[], text: string, at: number): Server[] =>
180 list.some(s => names(s, text)) ? list.map(s => (names(s, text) ? end(s, at, text) : s)) : list
181
182const RANK: Record<Server['status'], number> = { live: 0, seen: 1, failed: 2, stopped: 3 }
183export const serverOrder = (list: Server[]) =>
184 [...list].sort((a, b) => RANK[a.status] - RANK[b.status] || (b.lastUsedAt ?? b.startedAt) - (a.lastUsedAt ?? a.startedAt))
185
186/**
187 * What follows a server row's port and state, in at most `room` cells and with a leading space: when this session last hit it and
188 * how often, then what started it. The command is what is cut first, then the count, then the time.
189 */
190export const serverTail = (s: Server, now: number, room: number): string => {
191 const used = s.lastUsedAt !== null ? ` used ${fmtDuration(now - s.lastUsedAt)} ago` : ' not used yet'
192 const times = s.uses > 0 ? ` ·${s.uses}×` : ''
193 const from = s.command ? ` · ${s.command}` : s.status === 'seen' ? ' · started elsewhere' : ''
194 return fitSegments(
195 [used, times, from].filter(text => text !== '').map(text => ({ text })),
196 room,
197 )
198 .map(seg => seg.text)
199 .join('')
200 .replace(/ +…$/, '…')
201}
202hooks/model/tiers.ts 76 lines1// Which tier a model is, where a worker's model came from, and what its tokens cost (estimated).
2import type { ModelSource, Tier, Worker } from '../../types'
3import { fmtUsd, kTokens, prettyModel } from './util'
4
5export type TierRule = { tier: Tier; re: RegExp }
6export type Price = { in: number; out: number; cacheRead: number }
7export type PriceRule = { re: RegExp; price: Price }
8
9export const TIERS: readonly Tier[] = ['T0', 'T1', 'T2', 'T3']
10
11export const tierOf = (model: string, rules: TierRule[]): Tier | null => (model ? (rules.find(r => r.re.test(model))?.tier ?? null) : null)
12
13/**
14 * `requested`: the model passed on the Agent call, `null` when the call is known to have passed
15 * none, `undefined` when the call could not be linked to the spawn.
16 */
17export const modelSourceOf = (o: { requested: string | null | undefined; resolved: string; parent: string }): ModelSource => {
18 if (typeof o.requested === 'string' && o.requested !== '') return 'explicit'
19 const isParents = o.resolved !== '' && o.resolved === o.parent
20 if (o.requested === null) return isParents ? 'inherited' : 'definition-unpassed'
21 return isParents ? 'likely-inherited' : 'definition'
22}
23
24/** Every Agent call without an explicit `model` is flagged, whatever model it ran on; one whose call could not be linked and ran another model is not (unknown). */
25export const isFlagged = (s: ModelSource) => s === 'inherited' || s === 'likely-inherited' || s === 'definition-unpassed'
26
27const priceOf = (model: string, prices: PriceRule[]) => prices.find(p => p.re.test(model))?.price ?? null
28
29/** Tokens × the configured price; cache writes at 1.25× input. Null when the model has no price. */
30export const estUsd = (w: Pick<Worker, 'model' | 'tokIn' | 'tokCacheRead' | 'tokCacheWrite' | 'out'>, prices: PriceRule[]) => {
31 const p = priceOf(w.model, prices)
32 if (!p) return null
33 return (w.tokIn * p.in + w.tokCacheWrite * p.in * 1.25 + w.tokCacheRead * p.cacheRead + w.out * p.out) / 1_000_000
34}
35
36export type TierTotal = { tier: Tier; workers: number; tokens: number; usd: number | null }
37
38export const tierTotals = (workers: Worker[], tiers: TierRule[], prices: PriceRule[]): TierTotal[] =>
39 TIERS.map(tier => {
40 const ws = workers.filter(w => tierOf(w.model, tiers) === tier)
41 const usds = ws.map(w => estUsd(w, prices))
42 return {
43 tier,
44 workers: ws.length,
45 tokens: ws.reduce((s, w) => s + w.tokIn + w.tokCacheRead + w.tokCacheWrite + w.out, 0),
46 usd: ws.length === 0 || usds.some(u => u === null) ? null : usds.reduce<number>((s, u) => s + (u ?? 0), 0),
47 }
48 })
49
50/** `claude-sonnet-5-5` → `sonnet`. */
51export const family = (model: string) => (model ? (prettyModel(model).split(' ')[0] ?? model).toLowerCase() : '—')
52
53export type TierEntry = { tier: Tier; text: string }
54
55const STRIP_HEAD = 'tiers est'
56const tierEntry = (t: TierTotal, withUsd: boolean): TierEntry => ({
57 tier: t.tier,
58 text: ` ${t.tier} ${'▮'.repeat(Math.min(5, t.workers))} ${kTokens(t.tokens)}${withUsd && t.usd !== null ? ` ~${fmtUsd(t.usd)}` : ''}`,
59})
60const moreText = (n: number) => ` +${n}`
61
62/**
63 * The tiers strip cut to `width` cells: the `tiers est` label first, so the estimate is never what gets cut,
64 * then one entry per tier. Too wide: the estimated $ go first, then the last tiers (`more` counts them).
65 * At least one entry always stays.
66 */
67export const tierStrip = (totals: TierTotal[], width: number): { head: string; entries: TierEntry[]; more: number } => {
68 const cells = (es: TierEntry[], more: number) =>
69 STRIP_HEAD.length + es.reduce((n, e) => n + e.text.length, 0) + (more > 0 ? moreText(more).length : 0)
70 const full = totals.map(t => tierEntry(t, true))
71 if (cells(full, 0) <= width) return { head: STRIP_HEAD, entries: full, more: 0 }
72 let kept = totals.map(t => tierEntry(t, false))
73 while (kept.length > 1 && cells(kept, totals.length - kept.length) > width) kept = kept.slice(0, -1)
74 return { head: STRIP_HEAD, entries: kept, more: totals.length - kept.length }
75}
76hooks/model/util.ts 134 lines1// Ported from Flightdeck v0.4.0 (MIT, Stephen Casella), hooks/core.ts, plus Conductor additions.
2// Pure: nothing here touches `$`.
3import type { Bucket } from '../../types'
4
5export const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
6
7/** A stored object merged over its defaults, so a value saved under an older shape still reads. */
8export const normalize = <T extends object>(def: T, stored: unknown): T => (isObject(stored) ? ({ ...def, ...stored } as T) : def)
9
10/** A stored list, or empty when what is stored is not a list. */
11export const listOf = <T>(stored: unknown): T[] => (Array.isArray(stored) ? (stored as T[]) : [])
12
13/** `claude-opus-5-5` → `Opus 5.5`; anything else is shown as given. */
14/**
15 * A model id as people say it, from any provider's spelling: `claude-opus-5-5[1m]` → `Opus 5.5 1M`,
16 * `us.anthropic.claude-sonnet-4-5-20250929-v1:0` → `Sonnet 4.5`, `claude-3-5-haiku-20241022` →
17 * `Haiku 3.5`. Anything else is shown as given, cut to 22 characters.
18 */
19export const prettyModel = (id: string) => {
20 if (!id) return '—'
21 const cap = (f: string) => f.charAt(0).toUpperCase() + f.slice(1)
22 const big = /\[1m\]|-1m\b/i.test(id) ? ' 1M' : ''
23 const now = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(?![\d])/i.exec(id)
24 if (now?.[1] && !/^\d/.test(now[1])) return `${cap(now[1].toLowerCase())} ${now[2]}${now[3] ? `.${now[3]}` : ''}${big}`
25 const old = /claude-(\d+)(?:-(\d))?-([a-z]+)/i.exec(id)
26 if (old?.[3]) return `${cap(old[3].toLowerCase())} ${old[1]}${old[2] ? `.${old[2]}` : ''}${big}`
27 return shorten(id, 22)
28}
29
30export const shorten = (s: string, n: number) => {
31 const one = s.replace(/\s+/g, ' ').trim()
32 return n <= 0 ? '' : one.length > n ? `${one.slice(0, Math.max(0, n - 1)).trimEnd()}…` : one
33}
34
35/**
36 * Segments in order while they fit `room` cells. The first that does not is cut to what is left (ending in `…`)
37 * when at least 5 cells remain, and it and everything after are dropped.
38 */
39export const fitSegments = <T extends { text: string }>(segs: T[], room: number): T[] => {
40 const out: T[] = []
41 let left = Math.max(0, room)
42 for (const seg of segs) {
43 if (seg.text.length <= left) {
44 out.push(seg)
45 left -= seg.text.length
46 continue
47 }
48 if (left >= 5) out.push({ ...seg, text: `${seg.text.slice(0, left - 1)}…` })
49 break
50 }
51 return out
52}
53
54export const kTokens = (n: number) =>
55 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
56
57export const fmtDuration = (ms: number) => {
58 const s = Math.max(0, Math.round(ms / 1000))
59 if (s < 60) return `${s}s`
60 const m = Math.floor(s / 60)
61 return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
62}
63
64/** A running clock as the live cards draw it: m:ss, or XhYY past an hour. */
65export const fmtTimer = (ms: number) => {
66 const s = Math.max(0, Math.floor(ms / 1000))
67 const m = Math.floor(s / 60)
68 return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
69}
70
71export const fmtClock = (ms: number) => (ms > 0 ? new Date(ms).toTimeString().slice(0, 8) : '--:--:--')
72
73/** `1 error`, `2 errors`. */
74export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
75
76export const fmtUsd = (n: number) => (n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`)
77
78/** A gauge of `width` cells: ▰ filled, ▱ empty. */
79export const gauge = (pct: number, width: number) => {
80 const full = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
81 return { on: '▰'.repeat(full), off: '▱'.repeat(width - full) }
82}
83
84/** A bar of `width` cells: █ filled, ░ empty, by percent (clamped to 0–100). */
85export const blocks = (pct: number, width: number) => {
86 const full = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
87 return { on: '█'.repeat(full), off: '░'.repeat(width - full) }
88}
89
90/** A remaining time as people say it: `<1m`, `~12m`, `~1h05m`; empty when unknown. */
91export const fmtEta = (ms: number | null) => {
92 if (ms === null) return ''
93 const m = Math.round(ms / 60_000)
94 if (m < 1) return '<1m'
95 return m < 60 ? `~${m}m` : `~${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
96}
97
98const SECRETS: [RegExp, string][] = [
99 [/(authorization\s*[:=]\s*)(bearer\s+|basic\s+)?\S+/gi, '$1$2•••'],
100 [/\b(bearer)\s+[A-Za-z0-9._~+/-]{8,}=*/gi, '$1 •••'],
101 [/\b(sk|pk|rk|ghp|gho|ghs|github_pat|xox[abprs])[-_][A-Za-z0-9_-]{8,}/g, '•••'],
102 [/((?:api[_-]?key|access[_-]?token|token|secret|password|passwd|pwd)\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1•••'],
103 [/(--(?:token|password|api-key|secret)[= ])\S+/gi, '$1•••'],
104 [/(\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)=)\S+/g, '$1•••'],
105 [/(\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s@]+@/gi, '$1•••@'],
106]
107
108/** What the gate drill-down may store: credentials masked before anything is written to state. */
109export const redact = (s: string) => SECRETS.reduce((t, [re, to]) => t.replace(re, to), s)
110
111const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'NotebookEdit', 'Glob', 'Grep'])
112const SHELL_TOOLS = new Set(['Bash', 'PowerShell'])
113export const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
114
115export const bucketOf = (tool: string): Bucket => (FILE_TOOLS.has(tool) ? 'file' : SHELL_TOOLS.has(tool) ? 'shell' : 'other')
116
117/** One line saying what a call was about: its command, path or pattern; redacted. */
118export const describeInput = (tool: string, input: unknown) => {
119 const i = isObject(input) ? input : {}
120 const path = typeof i.file_path === 'string' ? i.file_path.split(/[\\/]/).slice(-2).join('/') : ''
121 const what =
122 typeof i.command === 'string'
123 ? i.command
124 : path ||
125 (typeof i.pattern === 'string'
126 ? i.pattern
127 : typeof i.url === 'string'
128 ? i.url
129 : typeof i.description === 'string'
130 ? i.description
131 : '')
132 return redact(what ? `${tool} → ${what}` : tool)
133}
134hooks/model/workflow.ts 144 lines1// Workflow runs: phases from the script's meta literal, agents from model loops no worker claims.
2import type { WorkflowAgent, WorkflowRun } from '../../types'
3import { fmtDuration, isObject, redact } from './util'
4
5const RUN_REF = /\bwf_[a-z0-9-]{6,}\b/i
6const RUN_REFS = new RegExp(RUN_REF.source, 'gi')
7const TOOL_USE_ID = /<tool-use-id>\s*([^<\s]+)\s*<\/tool-use-id>/gi
8const SUMMARY = /<summary>([\s\S]*?)<\/summary>/i
9export const MAX_RUNS = 5
10const MAX_AGENTS = 100
11/** A string literal in `'…'`, `"…"` or backticks, escaped quotes allowed: group 1 is the quote, group 2 what is inside it. */
12const QUOTED = /(['"`])((?:\\.|(?!\1)[^\\])*)\1/.source
13const NAME = new RegExp(`\\bname\\s*:\\s*${QUOTED}`)
14const TITLES = new RegExp(`\\btitle\\s*:\\s*${QUOTED}`, 'g')
15const unquote = (s: string) => s.replace(/\\(.)/g, '$1')
16
17/** The `export const meta = {…}` literal's name and phase titles; nothing outside it. */
18export const parseWorkflowMeta = (script: string): { name: string; phases: string[] } => {
19 const at = script.indexOf('export const meta')
20 if (at < 0) return { name: 'workflow', phases: [] }
21 const open = script.indexOf('{', at)
22 let depth = 0
23 let end = open
24 for (let i = open; i >= 0 && i < script.length; i += 1) {
25 if (script[i] === '{') depth += 1
26 else if (script[i] === '}' && --depth === 0) {
27 end = i
28 break
29 }
30 }
31 const meta = script.slice(open, end + 1)
32 const block = /\bphases\s*:\s*\[([\s\S]*)\]/.exec(meta)?.[1] ?? ''
33 const withoutPhases = meta.replace(block, '')
34 const name = unquote(NAME.exec(withoutPhases)?.[2] ?? '') || 'workflow'
35 const phases = [...block.matchAll(TITLES)].map(m => unquote(m[2] as string)).filter(title => title !== '')
36 return { name, phases }
37}
38
39/** A Workflow call's run name and phases as stored: from the script's meta literal, else the call's `name`; credentials masked. */
40export const workflowMetaOf = (input: Record<string, unknown>): { name: string; phases: string[] } => {
41 const meta =
42 typeof input.script === 'string'
43 ? parseWorkflowMeta(input.script)
44 : { name: typeof input.name === 'string' ? input.name : 'workflow', phases: [] as string[] }
45 return { name: redact(meta.name), phases: meta.phases.map(p => redact(p)) }
46}
47
48export const runRefOf = (text: string) => RUN_REF.exec(text)?.[0] ?? null
49
50export const startRun = (
51 runs: WorkflowRun[],
52 r: { id: string; ref: string | null; name: string; phases: string[]; at: number },
53): WorkflowRun[] =>
54 [
55 ...runs.filter(x => x.id !== r.id),
56 { id: r.id, ref: r.ref, name: r.name, phases: r.phases, startedAt: r.at, endedAt: null, agents: [] },
57 ].slice(-MAX_RUNS)
58
59export const activeRun = (runs: WorkflowRun[]) => [...runs].reverse().find(r => r.endedAt === null) ?? null
60
61export const stepRunAgent = (runs: WorkflowRun[], o: { agentId: string; phase: string | null; at: number }): WorkflowRun[] => {
62 const run = activeRun(runs)
63 if (!run) return runs
64 const found = run.agents.find(a => a.id === o.agentId)
65 const agents: WorkflowAgent[] = found
66 ? run.agents.map(a => (a === found ? { ...a, lastAt: o.at, phase: a.phase ?? o.phase } : a))
67 : [...run.agents, { id: o.agentId, phase: o.phase, isDone: false, firstAt: o.at, lastAt: o.at }].slice(-MAX_AGENTS)
68 return runs.map(r => (r === run ? { ...r, agents } : r))
69}
70
71export const finishRunAgent = (runs: WorkflowRun[], agentId: string, at: number): WorkflowRun[] =>
72 runs.map(r =>
73 r.agents.some(a => a.id === agentId)
74 ? { ...r, agents: r.agents.map(a => (a.id === agentId ? { ...a, isDone: true, lastAt: at } : a)) }
75 : r,
76 )
77
78/**
79 * A completion notice: ends the open run it names, matching the notice's `<tool-use-id>` to the run's own id first,
80 * then any `wf_` ref in the text to the run's ref. Only a notice with no `<tool-use-id>` at all, naming no known run,
81 * open or ended, ends the only active run, and then only if its `<summary>` (or its whole text, without one) says
82 * "workflow". A background Agent or Bash notice carries its own id and names its description in the summary, so it
83 * never ends a run by a word.
84 */
85export const endRunByText = (runs: WorkflowRun[], text: string, at: number): WorkflowRun[] => {
86 const ids = new Set([...text.matchAll(TOOL_USE_ID)].map(m => m[1]))
87 const refs = new Set(text.match(RUN_REFS) ?? [])
88 const isId = (r: WorkflowRun) => ids.has(r.id)
89 const isRef = (r: WorkflowRun) => r.ref !== null && refs.has(r.ref)
90 const isOpen = (r: WorkflowRun) => r.endedAt === null
91 const open = runs.filter(isOpen)
92 const names = runs.some(r => isId(r) || isRef(r))
93 const about = SUMMARY.exec(text)?.[1] ?? text
94 const isBare = ids.size === 0 && !names && open.length === 1 && /workflow/i.test(about)
95 const target = open.find(isId) ?? open.find(isRef) ?? (isBare ? open[0] : undefined)
96 return target ? runs.map(r => (r === target ? { ...r, endedAt: at } : r)) : runs
97}
98
99export type PhaseState = { title: string; done: number; total: number; state: 'done' | 'current' | 'waiting' }
100
101export const runProgress = (run: WorkflowRun) => {
102 const hasPhaseData = run.agents.some(a => a.phase !== null)
103 const counts = run.phases.map(title => {
104 const as = run.agents.filter(a => a.phase === title)
105 return { title, done: as.filter(a => a.isDone).length, total: as.length }
106 })
107 const lastStarted = counts.reduce((last, p, i) => (p.total > 0 ? i : last), -1)
108 const phases: PhaseState[] = counts.map((p, i) => ({
109 ...p,
110 state:
111 !hasPhaseData || i > lastStarted
112 ? 'waiting'
113 : i < lastStarted || run.endedAt !== null
114 ? 'done'
115 : p.done === p.total && i < counts.length - 1
116 ? 'done'
117 : 'current',
118 }))
119 return { done: run.agents.filter(a => a.isDone).length, total: run.agents.length, phases, hasPhaseData }
120}
121
122/** What ends the workflow row: how long a finished run took, or how many of its agents are done. */
123export const runTail = (run: WorkflowRun, p: Pick<ReturnType<typeof runProgress>, 'done' | 'total'>) =>
124 run.endedAt !== null ? ` ✓ done in ${fmtDuration(run.endedAt - run.startedAt)}` : ` ${p.done}/${p.total} agents`
125
126export type RunSegment = { text: string; state: PhaseState['state'] | 'plain' }
127
128/**
129 * The run's phases as row segments. With per-agent phase data (`hasPhaseData`) each phase carries its state:
130 * `Review ✓`, `Verify 1/2`, a waiting one bare. Without it (the live case: workflow agents report no phase)
131 * they are one plain segment, `Review › Verify`.
132 */
133export const runSegments = (run: WorkflowRun, p: ReturnType<typeof runProgress>): RunSegment[] =>
134 run.phases.length === 0
135 ? []
136 : p.hasPhaseData
137 ? p.phases.map((ph, i) => ({
138 text: `${i === 0 ? ' ' : ' › '}${ph.title}${ph.state === 'done' ? ' ✓' : ph.state === 'current' ? ` ${ph.done}/${ph.total}` : ''}`,
139 state: ph.state,
140 }))
141 : [{ text: ` ${run.phases.join(' › ')}`, state: 'plain' }]
142
143export const loopPhase = (e: unknown): string | null => (isObject(e) && typeof e.phase === 'string' && e.phase ? e.phase : null)
144