SLOPSHOPPER

conductor-mod

Conductor: a live pane for orchestrated Claude Code sessions

newpaneguardcommandstatusagents
v1.0.0MITupdated 2026-10-15cheeky-amit/conductor-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · conductor-mod
│ ┃ Conductor ✕ › fix the failing auth test and add an audit log call │ ┃ no plan yet · progress shows once tasks, a w │ ┃ ╭─────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ │ ORCHESTRATOR — ⎿ Read 6 lines │ ┃ ╰─────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ✓ last turn 42s · 0 workers · 3 edits · 1 er ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ╭─────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ │ session log │ ┃ │ 08:53:20 main Edit → src/auth.ts ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ 08:53:20 main Write → src/audit. │ ┃ │ 08:53:20 main Write → src/cache. ✻ Worked for 42s · done 4:20 PM │ ┃ │ 08:53:20 main Bash → bun test ✗ │ ┃ ╰─────────────────────────────────────────── › /conductor-mod │ ⎿ conductor-mod: conductor-mod open. In the hybrid layout, focus i │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ conductor-mod: ctx 49%

Draws

Pane · Conductor
no plan yet · progress shows once tasks, a workflow or … ╭──────────────────────────────────────────────────────╮ │ ORCHESTRATOR — ctx 49% │ ╰──────────────────────────────────────────────────────╯ ✓ last turn 42s · 0 workers · 3 edits · 1 error ╭──────────────────────────────────────────────────────╮ │ session log │ │ 08:53:20 main Edit → src/auth.ts │ │ 08:53:20 main Write → src/audit.ts │ │ 08:53:20 main Write → src/cache.ts │ │ 08:53:20 main Bash → bun test ✗ │ ╰──────────────────────────────────────────────────────╯
README

Conductor

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.

What it shows

RowSource
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 boxmain model and tier, context % (amber and red thresholds), ▶ now and → next
Worker boxesone 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 striptokens per tier, with an estimated $ labelled est
Plan stripone glyph per task: ▲ done, ◆ in progress, ◇ pending
Workflow stripworkflow name and phases from the script's meta, with agent counts
Servers striplocal 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 striparchitect or advisor consults on a mini timeline
Gate stripone cell per permission check, totals and denials
Logspawns, completions, edits, errors, denials, and the turn receipt

Install

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

Commands and keys

  • /conductor-mod open | close | reset | layout auto|hybrid|mini
  • In the hybrid layout, focus the pane with ctrl+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.

Configure

Set these under pluginConfigs["conductor-mod"].options, or with /config.

KeyDefaultWhat 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
contextAmber70Context gauge turns amber at this percent
contextRed80Context gauge turns red and says 'checkpoint now' at this percent
panelsprogress,crew,plan,workflow,servers,architect,gate,logComma-separated, in order: progress, crew, plan, workflow, servers, architect, gate, log. A panel left out is hidden
layoutautoauto: 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
maxBoxes3Worker 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
architectLabelARCHITECTName shown for the architect
gateLabelGATEName shown for the permission strip
palettethemetheme follows your Claude Code theme; pastel is fixed colours for dark terminals
openOnStarttrueOpen the pane when a session starts
statusLinetrueShow progress, ETA, context, running workers, flags and live servers in the status line

What is inferred

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.
  • ETA: mean duration of finished tasks times the tasks remaining. — until one task finishes.
  • Architect moments (before a plan, before done, error repeats): guessed from the edits and error streak so far in the turn.
  • Workflow progress: with no task list, the agents seen so far, so the total grows as they appear. It stops at 99% with ETA — until the run ends.
  • Workflow phase attribution: workflow agents report no phase, so per-phase counts show only when an event carries one. Otherwise the strip lists the script's phases without state.
  • Servers:
  • Job id shape: the ID: <token> in a background Bash result (4 to 32 letters, digits, _, -).
  • Stop tools: KillShell, KillBash and TaskStop, taking the job id as bash_id, shell_id, task_id or id.
  • Notice matching: a task notice ends the live server whose job id it names, as stopped or failed by its status or wording.
  • Port: the server's own flag, 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.
  • Port adoption: with exactly one live server started without a port, the first local port the session then uses is taken to be its own.

Credits

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.

Source 23 files
hooks/register.tsx 599 lines
1// 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}
599
hooks/model/architect.ts 109 lines
1// 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))
109
hooks/model/config.ts 136 lines
1// 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
136
hooks/model/crew.ts 101 lines
1// 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}
101
hooks/model/gate.ts 111 lines
1// 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}
111
hooks/model/log.ts 61 lines
1// 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}
61
hooks/model/plan.ts 109 lines
1// 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
109
hooks/model/progress.ts 75 lines
1// 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}
75
hooks/model/servers.ts 202 lines
1// 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}
202
hooks/model/tiers.ts 76 lines
1// 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}
76
hooks/model/util.ts 134 lines
1// 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}
134
hooks/model/workflow.ts 144 lines
1// 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