/board pane: tree of this session's agents with current tool, fails and tokens. A spawn that names no model gets one from Jev (OPENROUTER_API_KEY), else haiku…

Claude Code mods (hook plugins), published as the claude-mods marketplace.
The repo is private: the machine needs git access to GitHub first (gh auth login, then gh auth setup-git, or an SSH key).
/plugin marketplace add eluc1a/cc-plugins-mods-etc
/plugin install relay@claude-mods
/plugin install agent-board@claude-mods
Update:
/plugin marketplace update claude-mods
/plugin update relay@claude-mods
/plugin update agent-board@claude-mods
Keeps a long task going across context resets. Arm it per session with /relay on.
/clear waits until no background agent is running, then the session restarts from the handoff.RELAY: DONE ends it.Commands: /relay on, /relay off, /relay now (one handoff cycle at once).
/board opens a pane with a live tree of the session's agents: current tool, call times, fails, tokens.
Lane N: is grouped under a colored Lane N heading. The plugin adds this rule to the system prompt.OPENROUTER_API_KEY is set in the environment or in a .env in the session's directory. Otherwise Explore agents get haiku and the rest inherit./agent-board:board-test <test> launches cheap Haiku agents to exercise the pane. With no argument it lists the tests.~/.claude/agent-board/live.json for outside viewers, such as a browser village. The top level is { at, session, main, agents }, where each agent holds its info and a stat.workbench/: spend gate on paid commands, turn receipt on each turn. Load with claude --plugin-dir workbench.jev/: Jev experiments and evals. Run with jev/run.sh.From the repo root:
claude plugin test <plugin>
bunx -p typescript tsc -p <plugin>
claude plugin validate <plugin>
claude plugin validate .
A change reaches marketplace installs only with a push and a version bump in that plugin's .claude-plugin/plugin.json.
hooks/register.tsx 858 lines1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, AgentSpawnInput, AgentStatus, EngineInterface, Register, Timer, ToolCallInput } from 'claude-code'
3
4import type { AgentStat, BoardView } from '../types'
5import { diagram, fit } from './diagram'
6import type { Node } from './diagram'
7import { JEV_URL, MIN_CONFIDENCE, pick, question } from './route'
8import type { Pick } from './route'
9
10const PANE = 'agent-board'
11const MAIN = 'main'
12// The prompt-cache TTLs the transcripts show: 5 min in a subagent, 1 h on the main loop.
13// ponytail: fixed, usage overage drops main to 5 min and the board then calls a cold cache warm
14const COLD_MS = 5 * 60_000
15const MAIN_COLD_MS = 60 * 60_000
16const SOON_MS = 60_000
17// A working agent with no tool call or return for this long turns its tool line yellow: it may be stuck.
18// ponytail: one fixed limit, a long test run trips it too
19const QUIET_MS = 3 * 60_000
20const LONG_WORDS = 100
21const LAST_CALLS = 5
22// The dock width the board asks for; a width the person drags wins.
23const WIDTH = 46
24// The model and a star: `sonnet 5.5*`.
25const MODEL_W = 11
26const RIGHT_W = 7
27// The engine draws a hotkey before its label (`1: `), outside the tree's text.
28const KEY_W = 3
29// A lane under 6 cells shows nothing.
30const LANE_MIN = 6
31// 8 frames a second: the engine redraws a pane at most 10 times a second.
32const FRAME_MS = 125
33const FLASH_MS = 1000
34const FRAMES = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
35// The board's own dark palette, so it reads the same under any theme: each text color clears
36// 4.5:1 on bg (the tests check it). Theme keys fail here: the dark theme's `subtle` is 2.3:1.
37// Each bg channel stays under 26, so a 256-color terminal rounds bg to black, not navy.
38export const PALETTE = { bg: '#141019', text: '#e6e1f2', muted: '#9d97b8', working: '#ffb055', done: '#8bd6a8', failed: '#ff8f9b', soon: '#f2d27a' } as const
39const C = PALETTE
40// Each work lane's heading and card edges: purple, aqua, pink, blue, white, then again from purple. None is a status color.
41export const LANE_COLORS = ['#c08cff', '#5ce1e6', '#ff79e0', '#6aa6ff', '#f2f2f2'] as const
42// The input fields that name what a tool call works on: the first one present wins.
43const BRIEF_KEYS = ['description', 'file_path', 'command', 'pattern', 'url', 'query', 'skill', 'to']
44const ENDED: AgentStatus[] = ['completed', 'failed', 'killed']
45// Past this the spawn goes on with the mod's own rule.
46const JEV_TIMEOUT_MS = 3000
47// A work lane, from the start of an Agent description: `Lane 2: review the diff`.
48const LANE = /^lane (\d+):\s*/i
49// The rule that makes the model write that prefix, so the lanes need no CLAUDE.md on the machine.
50export const LANE_RULE = {
51 id: 'agent-board:lanes',
52 text:
53 'When you run work in parallel with subagents, start each Agent description with `Lane N: ` ' +
54 '(for example `Lane 1: implement the parser`). Give a follow-up agent for the same work (review, fix) ' +
55 'the same lane number. The agent board groups agents by this prefix.',
56 scope: 'session',
57} as const
58
59// $.state, so a save of this mod keeps the counts; a restart still forgets them.
60const agents = atom({ plugin: 'agent-board', key: 'agents' } as const, {} as Record<string, AgentStat>)
61const view = atom({ plugin: 'agent-board', key: 'view' } as const, { open: [], hideDone: false, still: false, cleared: [] } as BoardView)
62let timer: Timer | undefined
63let spin: Timer | undefined
64// The agents whose worktree the board looked for; a reload forgets them, and each costs one more look.
65const looked = new Set<string>()
66// The ended agents of a session that a /clear ended, until the next session's state takes them.
67// ponytail: a module variable, so a reload between the /clear and the next event loses the record; $.store keyed by process if that bites
68let carry: Record<string, AgentStat> | undefined
69
70type Kind = 'working' | 'idle' | 'done' | 'failed' | 'other'
71// `wait`: how long the current tool call has run, or how long ago the last one returned.
72// `isQuiet`: working, with no tool call or return for QUIET_MS.
73type Look = { kind: Kind; glyph: string; color: string; right: string; note?: string; noteColor?: string; cache?: string; wait?: string; isQuiet?: boolean }
74
75const title = (count: number) => `Agents · ${count}`
76const blank = (at: number): AgentStat => ({ tools: 0, fails: 0, last: [], at, isDone: false, tokens: 0, cached: 0, ms: 0 })
77const kilo = (n: number) =>
78 n < 1000 ? String(n) : n < 10_000 ? `${(n / 1000).toFixed(1)}k` : n < 999_500 ? `${Math.round(n / 1000)}k` : `${(n / 1e6).toFixed(1)}M`
79const minutes = (ms: number) => `${Math.ceil(ms / 60_000)}m`
80// `sonnet 5.5` for `claude-sonnet-5-5`, `sonnet 4` for `claude-sonnet-4-20250514`; an alias (`haiku`) shows
81// as it is. The full id shows in the details.
82export function short(model?: string) {
83 const m = model?.match(/(opus|sonnet|haiku|fable)(?:-(\d{1,2})(?!\d)(?:-(\d{1,2})(?!\d))?)?/)
84 if (m === null || m === undefined) {
85 return model?.replace(/^claude-/, '') ?? ''
86 }
87 const version = [m[2], m[3]].filter(x => x !== undefined).join('.')
88 return version === '' ? m[1]! : `${m[1]} ${version}`
89}
90const bar = (share: number) => {
91 const n = Math.min(5, Math.max(0, Math.ceil(share * 5)))
92 return '▰'.repeat(n) + '▱'.repeat(5 - n)
93}
94
95// One agent's run on the shared time axis [t0, t1], at least one cell so a quick run still shows.
96function lane(from: number, to: number, t0: number, t1: number, w: number) {
97 const cell = Math.max(1, t1 - t0) / w
98 const a = Math.max(0, Math.min(w - 1, Math.floor((from - t0) / cell)))
99 const b = Math.max(a, Math.min(w - 1, Math.ceil((to - t0) / cell) - 1))
100 return ' '.repeat(a) + '▆'.repeat(b - a + 1) + ' '.repeat(w - 1 - b)
101}
102
103function clock(ms: number) {
104 const s = Math.floor(ms / 1000)
105 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
106}
107
108function brief(e: ToolCallInput) {
109 const input = e as unknown as Record<string, unknown>
110 const value = BRIEF_KEYS.map(k => input[k]).find((v): v is string => typeof v === 'string' && v !== '')
111 const line = value?.split('\n')[0]
112
113 return line === undefined ? e.tool : `${e.tool}: ${line.startsWith('/') ? line.split('/').pop() : line}`
114}
115
116// Every write stamps the time, so the idle clock counts from the agent's last event.
117// The blank fills the fields a record from an older save of this mod lacks.
118async function touch($: EngineInterface, id: string, fn: (s: AgentStat, at: number) => Partial<AgentStat>) {
119 await adopt($)
120 const at = await $.clock.now()
121 await update($, agents, all => {
122 const s = { ...blank(at), ...all[id] }
123 return { ...all, [id]: { ...s, at, ...fn(s, at) } }
124 })
125 await publish($).catch(() => undefined)
126}
127
128// The live file the village page (~/dev/sub-ui) reads: the roster and each agent's record, rewritten on every event.
129// ponytail: one file for every session, the session that moved last wins; a file per session if two run at once
130export const LIVE = '.claude/agent-board/live.json'
131async function publish($: EngineInterface) {
132 const { value: stats = {} } = await $.state.get({ plugin: 'agent-board', key: 'agents' })
133 const { value: v } = await $.state.get({ plugin: 'agent-board', key: 'view' })
134 const list = roster(await $.agent.list(), stats, v)
135 const snapshot = {
136 at: await $.clock.now(),
137 session: await $.session.id(),
138 main: { ...stats[MAIN], model: await $.session.model() },
139 agents: list.map(a => ({ ...a, stat: stats[a.id] })),
140 }
141 await $.fs.write(`${await $.env.get('HOME')}/${LIVE}`, JSON.stringify(snapshot))
142}
143
144// The list entry of an agent, kept for its row once the engine drops its task.
145async function entry($: EngineInterface, id: string) {
146 const a = (await $.agent.list()).find(x => x.id === id)
147 return a && { description: a.description, type: a.type, name: a.name, parentId: a.parentId }
148}
149
150// The engine drops an ended subagent's task a while after it returns: the kept entry holds its row,
151// in spawn order, until `c` clears it. A cleared agent that runs again shows again. `p` hides the rows from before a /clear.
152function roster(list: AgentInfo[], stats: Record<string, AgentStat>, v: BoardView | undefined): AgentInfo[] {
153 const cleared = v?.cleared ?? []
154 const ids = new Set(list.map(a => a.id))
155 const kept = Object.entries(stats).flatMap(([id, s]): AgentInfo[] =>
156 s.info === undefined || ids.has(id)
157 ? []
158 : [{ ...s.info, id, status: s.end !== undefined && s.end !== 'answer' ? 'failed' : s.isDone ? 'completed' : 'killed' }],
159 )
160 const rank = new Map(Object.keys(stats).map((id, i) => [id, i]))
161 const at = (id: string) => rank.get(id) ?? rank.size
162
163 return [...list, ...kept]
164 .filter(a => !(cleared.includes(a.id) && ENDED.includes(a.status)) && !(v?.hidePast === true && stats[a.id]?.isPast === true))
165 .sort((a, b) => at(a.id) - at(b.id))
166}
167
168// At a /clear: the ended agents on the board, each kept whole and marked, so the next session shows them as a record.
169async function archive($: EngineInterface) {
170 const { value: stats = {} } = await $.state.get({ plugin: 'agent-board', key: 'agents' })
171 const { value: v } = await $.state.get({ plugin: 'agent-board', key: 'view' })
172 const list = roster(await $.agent.list(), stats, v ? { ...v, hidePast: false } : v)
173 return Object.fromEntries(
174 list
175 .filter(a => ENDED.includes(a.status))
176 .map(a => {
177 const s = { ...blank(0), ...stats[a.id] }
178 const info = s.info ?? { description: a.description, type: a.type, name: a.name, parentId: a.parentId }
179 return [a.id, { ...s, info, isDone: a.status === 'completed', isPast: true }]
180 }),
181 )
182}
183
184// The first write of the session after a /clear puts the record first, ahead of the new session's agents.
185async function adopt($: EngineInterface) {
186 if (carry === undefined) {
187 return
188 }
189 const past = carry
190 carry = undefined
191 await update($, agents, all => ({ ...past, ...Object.fromEntries(Object.entries(all).filter(([id]) => !(id in past))) }))
192}
193
194// OPENROUTER_API_KEY from the environment, else from a .env in the session's directory.
195async function jevKey($: EngineInterface) {
196 const env = await $.env.get('OPENROUTER_API_KEY').catch(() => undefined)
197 if (env) {
198 return env
199 }
200 const file = await $.fs.read('.env').catch(() => '')
201 return typeof file === 'string' ? file.match(/^OPENROUTER_API_KEY=["']?([^"'\s]+)/m)?.[1] : undefined
202}
203
204// Jev's tier for a spawn, or the reason the mod's own rule decides instead.
205async function route($: EngineInterface, e: AgentSpawnInput): Promise<Pick | string> {
206 const key = await jevKey($)
207 if (key === undefined) {
208 return 'no key'
209 }
210 const ask = $.http.fetch(JEV_URL, {
211 method: 'POST',
212 headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
213 body: JSON.stringify(question(e)),
214 })
215 const res = await Promise.race([ask, $.clock.sleep(JEV_TIMEOUT_MS).then(() => undefined)])
216 if (res === undefined) {
217 return 'timeout'
218 }
219 if (!res.ok) {
220 return `http ${res.status}`
221 }
222 const p = pick(res.text)
223 if (p === undefined) {
224 return 'bad answer'
225 }
226
227 return p.confidence < MIN_CONFIDENCE ? `${p.tier} at ${p.confidence.toFixed(2)}, low` : p
228}
229
230// The branch of an agent spawned to run in a directory other than the session's.
231async function branchOf($: EngineInterface, cwd: string | undefined) {
232 if (cwd === undefined || cwd === (await $.session.cwd())) {
233 return undefined
234 }
235 const r = await $.process.run(['git', 'branch', '--show-current'], { cwd, timeoutMs: 2000 }).catch(() => undefined)
236 return r?.exitCode === 0 && r.stdout.trim() !== '' ? r.stdout.trim() : undefined
237}
238
239// The branch of a worktree agent: the spawn gives no cwd for it, but its worktree is `agent-<id>`.
240// ponytail: that name is what the engine made in a live test, not a documented contract
241async function worktreeBranch($: EngineInterface, id: string) {
242 const r = await $.process.run(['git', 'worktree', 'list', '--porcelain'], { cwd: await $.session.cwd(), timeoutMs: 2000 }).catch(() => undefined)
243 // One block per worktree: `worktree <path>`, `HEAD <sha>`, `branch refs/heads/<name>`.
244 const block = r?.exitCode === 0 ? r.stdout.split('\n\n').find(b => b.split('\n')[0]?.endsWith(`/agent-${id}`)) : undefined
245 return block?.match(/^branch refs\/heads\/(.+)$/m)?.[1]
246}
247
248async function refresh($: EngineInterface) {
249 const isRunning = (await $.agent.list()).some(a => a.status === 'running')
250 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
251 // A kill or an end raises no event of the mod's: the refresh carries the new status to the village.
252 await publish($).catch(() => undefined)
253 if (!isRunning && !isOpen) {
254 timer?.cancel()
255 timer = undefined
256 return
257 }
258 if (isOpen) {
259 await show($)
260 }
261 $.ui.invalidate('ui.render')
262}
263
264function watch($: EngineInterface) {
265 timer ??= $.clock.every(10_000, () => void refresh($))
266}
267
268// The open board moves while a spinner turns or a flash shows; motion off, it never does.
269async function isMoving($: EngineInterface) {
270 const { value: v } = await $.state.get({ plugin: 'agent-board', key: 'view' })
271 if (v?.still === true || !(await $.ui.panes()).some(p => p.id === PANE)) {
272 return false
273 }
274 const { value: all = {} } = await $.state.get({ plugin: 'agent-board', key: 'agents' })
275 const now = await $.clock.now()
276 const isFlashing = Object.values(all).some(s => s.flashAt !== undefined && now - s.flashAt < FLASH_MS)
277 const isBusy = all[MAIN]?.isDone === false || (await $.agent.list()).some(a => a.status === 'running' && all[a.id]?.isDone !== true)
278
279 return isFlashing || isBusy
280}
281
282// Redraws 8 times a second, and stops itself once nothing moves: an idle board costs nothing.
283async function tick($: EngineInterface) {
284 if (!(await isMoving($))) {
285 spin?.cancel()
286 spin = undefined
287 }
288 $.ui.invalidate('ui.render')
289}
290
291function animate($: EngineInterface) {
292 spin ??= $.clock.every(FRAME_MS, () => void tick($))
293}
294
295// Each open sets closeOnEscape and the asked width anew, so the refresh opens with them too.
296async function show($: EngineInterface) {
297 await adopt($)
298 const { value: stats = {} } = await $.state.get({ plugin: 'agent-board', key: 'agents' })
299 const { value: v } = await $.state.get({ plugin: 'agent-board', key: 'view' })
300 const count = roster(await $.agent.list(), stats, v).length
301 await $.ui.open({ id: PANE, title: title(count), closeOnEscape: true, columns: WIDTH })
302}
303
304async function open($: EngineInterface) {
305 await show($)
306 watch($)
307 animate($)
308}
309
310export const register: Register = on => {
311 on('session.start', async ($, e, next) => {
312 await $.command.register({ name: 'board', description: 'Show the agent tree of this session in a pane' })
313
314 return next(e)
315 })
316
317 // A /clear ends the session's state; the process goes on, so the record waits in `carry`.
318 on('session.end', async ($, e, next) => {
319 if (e.reason === 'clear') {
320 carry = await archive($).catch(() => undefined)
321 }
322
323 return next(e)
324 })
325
326 // Only where the model can spawn an agent.
327 on('prompt.compose', async ($, e, next) => {
328 const composed = await next(e)
329 return e.tools.includes('Agent') ? { sections: [...composed.sections, LANE_RULE] } : composed
330 })
331
332 on('command.run', { command: 'board' }, async $ => {
333 await open($)
334
335 return { text: 'Agent board opened.' }
336 })
337
338 // A spawn that names no model: Jev picks it, else Explore gets haiku and the rest inherit.
339 on('agent.spawn', async ($, e, next) => {
340 const jev = e.model === undefined ? await route($, e).catch(err => `error ${String(err)}`) : undefined
341 const set = typeof jev === 'object' ? jev.tier : e.subagentType === 'Explore' && e.model === undefined ? 'haiku' : undefined
342 const why =
343 typeof jev === 'object' ? `jev ${jev.tier} at ${jev.confidence.toFixed(2)}` : jev === undefined ? undefined : `${set ?? 'inherit'}, jev: ${jev}`
344 const ran = await next(set === undefined ? e : { ...e, model: set })
345 if (ran.deny === undefined && ran.agentId !== undefined) {
346 const { model, agentId } = ran
347 const info = await entry($, agentId)
348 const branch = await branchOf($, e.cwd)
349 await touch($, agentId, (s, at) => ({
350 model,
351 isStar: set !== undefined,
352 route: why,
353 isDone: false,
354 since: at,
355 start: s.start ?? at,
356 info: info ?? s.info,
357 branch: branch ?? s.branch,
358 }))
359 watch($)
360 animate($)
361 }
362
363 return ran
364 })
365
366 on('tool.call', async ($, e, next) => {
367 const id = e.agentId ?? MAIN
368 const call = { id: e.tool_use_id, label: brief(e) }
369 // A teammate's later turn raises no turn.start: its first tool call starts the clock.
370 await touch($, id, (s, at) => ({
371 tools: s.tools + 1,
372 tool: call.label,
373 last: [...s.last, { ...call, at }].slice(-LAST_CALLS),
374 isDone: false,
375 since: s.since ?? at,
376 start: s.start ?? at,
377 }))
378 animate($)
379 // By an agent's first tool call its worktree exists.
380 if (e.agentId !== undefined && !looked.has(e.agentId)) {
381 looked.add(e.agentId)
382 const branch = await worktreeBranch($, e.agentId).catch(() => undefined)
383 if (branch !== undefined) {
384 await touch($, e.agentId, () => ({ branch }))
385 }
386 }
387 const ran = await next(e)
388 const isFail = ran.deny !== undefined || ran.isError === true
389 const isToMain = !isFail && e.agentId !== undefined && e.tool === 'SendMessage' && e.to === MAIN
390 // The return stops the call's clock; a failed one also counts and flashes.
391 await touch($, id, (s, at) => ({
392 last: s.last.map(c => (c.id === call.id ? { ...c, end: at, ...(isFail ? { isError: true } : {}) } : c)),
393 ...(isFail ? { fails: s.fails + 1, flashAt: at } : {}),
394 ...(isToMain ? { isToMain: true } : {}),
395 }))
396 if (isFail) {
397 animate($)
398 }
399
400 return ran
401 })
402
403 // Only the main loop raises turn.start.
404 on('turn.start', async ($, e, next) => {
405 await touch($, MAIN, (_s, at) => ({ isDone: false, since: at, tool: undefined }))
406 animate($)
407
408 return next(e)
409 })
410
411 on('turn.complete', async ($, e, next) => {
412 const u = e.usage
413 const tokens = u === undefined ? 0 : u.input_tokens + u.output_tokens + u.cache_creation_input_tokens
414 const cached = u?.cache_read_input_tokens ?? 0
415 const answer = e.answer.trim()
416 const isSub = e.agentId !== undefined
417 const info = e.agentId === undefined ? undefined : await entry($, e.agentId)
418 await touch($, e.agentId ?? MAIN, (s, at) => ({
419 isDone: true,
420 tokens: s.tokens + tokens,
421 cached: s.cached + cached,
422 ms: s.ms + e.durationMs,
423 since: undefined,
424 tool: undefined,
425 end: e.reason,
426 // A subagent's return flashes its row and keeps its report's length.
427 ...(isSub ? { flashAt: at, report: answer === '' ? 0 : answer.split(/\s+/).length, info: info ?? s.info } : {}),
428 }))
429 if (isSub) {
430 animate($)
431 }
432
433 return next(e)
434 })
435
436 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
437 const n = (await $.agent.list()).filter(a => a.status === 'running').length
438 if (n === 0) {
439 return next(e)
440 }
441
442 // The suffix replaces the engine's ellipsis, so the engine still draws its own spinner.
443 return next({ ...e, props: { ...e.props, suffix: ` · ${n} agent${n === 1 ? '' : 's'} running…` } })
444 })
445
446 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
447 const { Box, Text, Button } = $.ui.resolve(e)
448 const now = await $.clock.now()
449 const stats = await read($, agents)
450 const v = await read($, view)
451 const list = roster(await $.agent.list(), stats, v)
452 const width = e.props.bodyColumns || WIDTH
453 const stat = (id: string) => ({ ...blank(now), ...stats[id] })
454 const ids = new Set(list.map(a => a.id))
455 const laneOf = (a: AgentInfo) => a.description.match(LANE)?.[1]
456 // Lane 1 takes the first color, lane 6 the first again.
457 const edgeOf = (a: AgentInfo) => {
458 const n = laneOf(a)
459 return n === undefined ? C.muted : LANE_COLORS[(Number(n) + LANE_COLORS.length - 1) % LANE_COLORS.length]!
460 }
461 // Siblings with each lane together: a lane shows where its first agent started, its agents in start order.
462 const byLane = (sibs: AgentInfo[]) => {
463 const rank = (a: AgentInfo) => sibs.findIndex(b => b === a || (laneOf(a) !== undefined && laneOf(b) === laneOf(a)))
464 return [...sibs].sort((x, y) => rank(x) - rank(y))
465 }
466 // An agent whose parent left the list hangs under main.
467 const kids = (parent?: string) => byLane(list.filter(a => (a.parentId !== undefined && ids.has(a.parentId) ? a.parentId : undefined) === parent))
468 // An agent of main's drops its lane from its name: the lane's heading shows it.
469 const label = (a: AgentInfo, isTop: boolean) => a.name ?? (isTop ? a.description.replace(LANE, '') : a.description)
470
471 const look = (status: string, s: AgentStat, coldMs: number): Look => {
472 // A teammate that waits for a message is `idle`: live, not failed.
473 const isLive = status === 'running' || status === 'idle'
474 const isIdle = status === 'idle' || (status === 'running' && s.isDone)
475 const end = s.end !== undefined && s.end !== 'answer' ? s.end : undefined
476 const ms = s.ms + (status === 'running' && !isIdle && s.since !== undefined ? now - s.since : 0)
477 const time = ms > 0 ? clock(ms) : ''
478 const left = coldMs - (now - s.at)
479 if (status === 'failed' || status === 'killed' || (end !== undefined && !isLive)) {
480 return { kind: 'failed', glyph: '✗', color: C.failed, right: end ?? status }
481 }
482 if (status === 'completed') {
483 return { kind: 'done', glyph: '✓', color: C.done, right: time }
484 }
485 if (status === 'running' && !isIdle) {
486 const glyph = v.still ? '●' : (FRAMES[Math.floor(now / FRAME_MS) % FRAMES.length] ?? '●')
487 // s.tool is set by this turn's calls only: main's list keeps the calls of its earlier turns.
488 const call = s.tool === undefined ? undefined : s.last.at(-1)
489 const isCalling = call?.at !== undefined && call.end === undefined
490 // Since the running call started, the last call returned, or else the turn started.
491 const quiet = now - (isCalling ? call.at! : (call?.end ?? s.since ?? now))
492 const wait = call === undefined ? undefined : isCalling ? clock(quiet) : `${clock(quiet)} ago`
493 const isQuiet = quiet >= QUIET_MS
494 const note = s.tool ?? (isQuiet ? `no tool call · ${clock(quiet)}` : undefined)
495 return { kind: 'working', glyph, color: C.working, right: time, note, noteColor: isQuiet ? C.soon : undefined, cache: 'warm', wait, isQuiet }
496 }
497 if (isIdle) {
498 const color = left < SOON_MS ? C.soon : C.muted
499 const note = left <= 0 ? 'idle · cache cold' : `idle · cache ${minutes(left)} left`
500 return { kind: 'idle', glyph: '○', color, right: time, note, noteColor: color, cache: left <= 0 ? `${bar(0)} cold` : `${bar(left / coldMs)} ${minutes(left)} left` }
501 }
502 return { kind: 'other', glyph: '·', color: C.muted, right: status }
503 }
504
505 const looks = new Map(list.map(a => [a.id, look(a.status, stat(a.id), COLD_MS)]))
506 // A returned agent with no live agent under it: `h` hides it. `c` clears it, and a failed one too.
507 const isDoneTree = (a: AgentInfo): boolean => looks.get(a.id)?.kind === 'done' && kids(a.id).every(isDoneTree)
508 const isEndedTree = (a: AgentInfo): boolean => ['done', 'failed'].includes(looks.get(a.id)?.kind ?? '') && kids(a.id).every(isEndedTree)
509 const isHidden = (a: AgentInfo) => v.hideDone && isDoneTree(a)
510 // The lanes' time axis: from the first shown start to now, or to the last end once nothing works.
511 const spans = new Map(
512 list.flatMap(a => {
513 const s = stats[a.id]
514 const l = looks.get(a.id)
515 return s?.start === undefined || l === undefined || isHidden(a) ? [] : [[a.id, [s.start, l.kind === 'working' ? now : s.at]] as const]
516 }),
517 )
518 const t0 = Math.min(...[...spans.values()].map(r => r[0]))
519 const t1 = Math.max(...[...spans.values()].map(r => r[1]))
520 // Each agent of main's draws in a card; the agents it started sit inside, under tree lines.
521 // The lanes all start in one column, past the model of the deepest agent shown.
522 const inner = width - 4
523 const top = kids(undefined).filter(a => !isHidden(a))
524 const depth = (id: string, d: number): number => Math.max(d, ...kids(id).filter(a => !isHidden(a)).map(a => depth(a.id, d + 1)))
525 const deepest = Math.max(0, ...top.map(a => depth(a.id, 0)))
526 const laneX = (deepest === 0 ? 0 : 2 + 2 * deepest) + 2 + MODEL_W + 1
527 const laneW = inner - laneX >= LANE_MIN ? inner - laneX : 0
528 const clear = () =>
529 void update($, view, w => ({ ...w, cleared: [...(w.cleared ?? []), ...list.filter(isEndedTree).map(a => a.id)] })).then(() => show($))
530 const toggle = (id: string) => () =>
531 void update($, view, w => ({ ...w, open: w.open.includes(id) ? w.open.filter(x => x !== id) : [...w.open, id] }))
532
533 // Text keeps no key, so each keyed piece sits in a Box of its own.
534 // The lines under an agent's name: its current tool or idle note, and its details when expanded.
535 // In a card each line fills the card between its edges, in `edge`; main's lines have no edges.
536 const under = (id: string, cont: string, s: AgentStat, l: Look, edge?: string) => {
537 const isCard = edge !== undefined
538 const w = isCard ? inner : width
539 const cut = (t: string, n: number) => (isCard ? fit(t, n).padEnd(n) : fit(t, n))
540 // Dim labels, bright values. `tail` goes last, so the value gives way to it; in a card it sits at the right edge.
541 const detail = (key: string, head: string, value: string, color: string = C.text, tail = '') => {
542 const lead = `${cont} ${head.padEnd(8)}`
543 return (
544 <Box key={key} flexDirection="row">
545 {isCard && <Text color={edge}>│ </Text>}
546 <Text color={C.muted}>{lead}</Text>
547 <Text color={color}>{`${cut(value, w - lead.length - tail.length)}${tail}`}</Text>
548 {isCard && <Text color={edge}> │</Text>}
549 </Box>
550 )
551 }
552 const words = s.report
553
554 return [
555 l.note !== undefined && (
556 <Box key={`now-${id}`} flexDirection="row">
557 {isCard && <Text color={edge}>│ </Text>}
558 <Text color={l.noteColor ?? C.muted}>
559 {/* The clock goes last, so the tool's name gives way to it. */}
560 {cut(`${cont} └ ${l.wait === undefined ? l.note : `${fit(l.note, w - cont.length - 7 - l.wait.length)} · ${l.wait}`}`, w)}
561 </Text>
562 {isCard && <Text color={edge}> │</Text>}
563 </Box>
564 ),
565 v.open.includes(id) && (
566 <Box key={`detail-${id}`} flexDirection="column">
567 {s.model !== undefined && detail(`model-${id}`, 'model', s.route === undefined ? s.model : `${s.model} · ${s.route}`)}
568 {detail(`tokens-${id}`, 'tokens', s.tokens + s.cached > 0 ? `${kilo(s.tokens)} + ${kilo(s.cached)} cached` : '—')}
569 {detail(`tools-${id}`, 'tools', `${s.tools}${s.fails > 0 ? ` ✗${s.fails}` : ''}`, s.fails > 0 ? C.failed : undefined)}
570 {l.cache !== undefined && detail(`cache-${id}`, 'cache', l.cache, l.kind === 'idle' && l.noteColor === C.soon ? C.soon : undefined)}
571 {s.start !== undefined && detail(`started-${id}`, 'started', `${clock(now - s.start)} ago`)}
572 {words !== undefined &&
573 detail(`report-${id}`, 'report', words === 0 ? 'none' : `${words} words${words > LONG_WORDS ? ' · long' : ''}`)}
574 {s.last.map((c, i) => {
575 const ago = c.at === undefined ? '' : ` ${clock(now - c.at)} ago`
576 return detail(`last-${id}-${i}`, i === 0 ? 'last' : '', `${c.label}${c.isError === true ? ' ✗' : ''}`, c.isError === true ? C.failed : undefined, ago)
577 })}
578 </Box>
579 ),
580 ]
581 }
582
583 // An agent's lines in a card: glyph, name and time, then model and lane, then the lines under them.
584 // `pad` draws the tree in front of the name, `cont` the tree in front of the lines under it.
585 // A nested agent's ✉ shows on its name, yellow: it skipped its parent. A card's own ✉ is in its title.
586 const rows = (a: AgentInfo, pad: string, cont: string, edge: string) => {
587 const id = a.id
588 const s = stat(id)
589 const l = looks.get(id) ?? look(a.status, s, COLD_MS)
590 const isFlash = !v.still && s.flashAt !== undefined && now - s.flashAt < FLASH_MS
591 const mark = pad !== '' && s.isToMain === true ? '✉ ' : ''
592 const nameW = Math.max(4, inner - pad.length - 2 - mark.length - 1 - RIGHT_W)
593 const name = fit(label(a, pad === ''), nameW)
594 const lead = fit(`${cont} ${fit(`${short(s.model)}${s.isStar === true ? '*' : ''}`, MODEL_W)}`, inner)
595 const run = spans.get(id)
596
597 return [
598 <Box key={`row-${id}`} flexDirection="row">
599 <Text color={edge}>│ </Text>
600 <Text color={C.muted}>{pad}</Text>
601 <Box key={`glyph-${id}`}>
602 <Text color={l.color} inverse={isFlash} bold={isFlash}>{l.glyph}</Text>
603 </Box>
604 <Text> </Text>
605 <Button key={`open-${id}`} plain label={name} onPress={toggle(id)}>
606 {mark !== '' && <Text color={C.soon}>{mark}</Text>}
607 <Text color={l.color}>{name}</Text>
608 </Button>
609 <Text>{' '.repeat(nameW - name.length + 1)}</Text>
610 <Box key={`right-${id}`}>
611 <Text color={l.color} inverse={isFlash}>
612 {fit(l.right, RIGHT_W).padStart(RIGHT_W)}
613 </Text>
614 </Box>
615 <Text color={edge}> │</Text>
616 </Box>,
617 <Box key={`meta-${id}`} flexDirection="row">
618 <Text color={edge}>│ </Text>
619 <Text color={C.muted}>{lead.padEnd(laneW > 0 ? laneX : inner)}</Text>
620 {laneW > 0 && (
621 <Box key={`lane-${id}`}>
622 <Text color={l.color}>{run === undefined ? ' '.repeat(laneW) : lane(run[0], run[1], t0, t1, laneW)}</Text>
623 </Box>
624 )}
625 <Text color={edge}> │</Text>
626 </Box>,
627 ...under(id, cont, s, l, edge),
628 ]
629 }
630
631 // A card's title gives its key, its state and what it holds: `╭─ 2: working · 2 agents inside ──╮`.
632 const card = (a: AgentInfo, digit?: string) => {
633 const s = stat(a.id)
634 const l = looks.get(a.id) ?? look(a.status, s, COLD_MS)
635 const edge = edgeOf(a)
636 const lines = rows(a, '', '', edge)
637 const own = lines.length
638 let inside = 0
639 const walk = (parent: string, pad: string) => {
640 const shown = kids(parent).filter(k => !isHidden(k))
641 shown.forEach((k, i) => {
642 const isLast = i === shown.length - 1
643 const cont = `${pad}${isLast ? ' ' : '│ '}`
644 inside += 1
645 lines.push(...rows(k, `${pad}${isLast ? '└ ' : '├ '}`, cont, edge))
646 walk(k.id, cont)
647 })
648 }
649 walk(a.id, ' ')
650 if (inside > 0) {
651 lines.splice(own, 0, <Box key={`gap-${a.id}`}><Text color={edge}>{`│ ${' '.repeat(inner)} │`}</Text></Box>)
652 }
653 const state = l.kind === 'other' ? a.status : l.kind
654 const what = [
655 s.isPast === true ? 'before /clear' : '',
656 inside > 0 ? `${inside} agent${inside === 1 ? '' : 's'} inside` : '',
657 s.isToMain === true ? '✉ sent to main' : '',
658 ]
659 .filter(x => x !== '')
660 .map(x => ` · ${x}`)
661 .join('')
662 const keyW = digit === undefined ? 0 : KEY_W
663 const extra = fit(what, Math.max(0, width - keyW - 6 - state.length))
664
665 return (
666 <Box key={`card-${a.id}`} flexDirection="column">
667 <Box key={`title-${a.id}`} flexDirection="row">
668 <Text color={edge}>╭─ </Text>
669 {digit === undefined ? (
670 <Text color={l.color}>{state}</Text>
671 ) : (
672 <Button key={`key-${a.id}`} plain hotkey={digit} label={state} onPress={toggle(a.id)}>
673 <Text color={l.color}>{state}</Text>
674 </Button>
675 )}
676 <Text color={C.muted}>{extra}</Text>
677 <Text color={edge}>{` ${'─'.repeat(Math.max(1, width - keyW - 5 - state.length - extra.length))}╮`}</Text>
678 </Box>
679 {lines}
680 <Box key={`end-${a.id}`}>
681 <Text color={edge}>{`╰${'─'.repeat(width - 2)}╯`}</Text>
682 </Box>
683 </Box>
684 )
685 }
686
687 // The heading over the first of a lane's agents of main's: `Lane 1 · feat-x`, the branch of the first that has one.
688 const heading = (a: AgentInfo, i: number) => {
689 const n = laneOf(a)
690 if (n === undefined || (i > 0 && laneOf(top[i - 1]!) === n)) {
691 return undefined
692 }
693 const branch = top.map(b => (laneOf(b) === n ? stats[b.id]?.branch : undefined)).find(b => b !== undefined)
694 return branch === undefined ? `Lane ${n}` : `Lane ${n} · ${branch}`
695 }
696
697 // Main heads the board on one line, outside the cards: key 0, model and time.
698 const main = { ...blank(now), isDone: true, ...stats[MAIN], model: await $.session.model() }
699 const mainLook = look('running', main, MAIN_COLD_MS)
700 const mainFlash = !v.still && main.flashAt !== undefined && now - main.flashAt < FLASH_MS
701 const mainModel = short(main.model)
702 // Cards take the keys 1 to 9 in the order they show.
703 const out = [
704 <Box key={`row-${MAIN}`} flexDirection="row">
705 <Box key={`glyph-${MAIN}`}>
706 <Text color={mainLook.color} inverse={mainFlash} bold={mainFlash}>{mainLook.glyph}</Text>
707 </Box>
708 <Text> </Text>
709 <Button key={`open-${MAIN}`} plain hotkey="0" label={MAIN} onPress={toggle(MAIN)}>
710 <Text color={mainLook.color}>{MAIN}</Text>
711 </Button>
712 <Text color={C.muted}>{` ${mainModel}${' '.repeat(Math.max(1, width - KEY_W - 9 - mainModel.length - RIGHT_W))}`}</Text>
713 <Box key={`right-${MAIN}`}>
714 <Text color={mainLook.color} inverse={mainFlash}>
715 {fit(mainLook.right, RIGHT_W).padStart(RIGHT_W)}
716 </Text>
717 </Box>
718 </Box>,
719 ...under(MAIN, '', main, mainLook),
720 top.length > 0 && (
721 <Box key="gap">
722 <Text> </Text>
723 </Box>
724 ),
725 ...top.flatMap((a, i) => {
726 const head = heading(a, i)
727 return [
728 head !== undefined && (
729 <Box key={`heading-${a.id}`}>
730 <Text color={edgeOf(a)}>{fit(head, width)}</Text>
731 </Box>
732 ),
733 card(a, i < 9 ? String(i + 1) : undefined),
734 ]
735 }),
736 ]
737
738 // The diagram view: the same tree as boxes, its lines pulsing toward the working agents.
739 const meta = (model: string | undefined, right: string) => [short(model), right].filter(x => x !== '').join(' · ')
740 // A lane's boxes, and the boxes of the agents they started, take the lane's color.
741 const node = (a: AgentInfo, isTop: boolean, edge: string): Node => {
742 const l = looks.get(a.id) ?? look(a.status, stat(a.id), COLD_MS)
743 const below = kids(a.id).filter(k => !isHidden(k))
744 const own = { label: label(a, isTop), glyph: l.glyph, color: l.color, meta: meta(stat(a.id).model, l.right), isLive: l.kind === 'working', edge }
745 return { ...own, kids: below.map(k => node(k, false, edge)) }
746 }
747 const root: Node = {
748 label: MAIN,
749 glyph: mainLook.glyph,
750 color: mainLook.color,
751 meta: meta(main.model, mainLook.right),
752 isLive: false,
753 kids: top.map((a, i) => ({ ...node(a, true, edgeOf(a)), title: heading(a, i) })),
754 }
755 const dg = v.diagram === true ? diagram(root, width, v.still ? undefined : Math.floor(now / FRAME_MS), C.muted, C.working) : undefined
756
757 const counts = (['working', 'idle', 'failed', 'done'] as const)
758 .map(kind => ({ kind, n: [...looks.values()].filter(l => l.kind === kind).length }))
759 .filter(c => c.n > 0)
760 const color = { working: C.working, idle: C.muted, failed: C.failed, done: C.done }
761 const isStarred = list.some(a => stats[a.id]?.isStar === true)
762 const isQuiet = [mainLook, ...looks.values()].some(l => l.isQuiet === true)
763 const pastN = Object.values(stats).filter(s => s.isPast === true).length
764 const isSkipped = list.some(a => a.parentId !== undefined && ids.has(a.parentId) && stats[a.id]?.isToMain === true && !isHidden(a))
765
766 // Docked, the color fills the whole body; inline, the frame fits the tree.
767 return (
768 <Box flexDirection="column" backgroundColor={C.bg} minHeight={e.props.placement === 'dock' ? e.props.scroll.bodyRows : undefined}>
769 <Box key="summary" flexDirection="row">
770 {counts.length === 0 && <Text color={C.muted}>no subagents yet</Text>}
771 {counts.map((c, i) => (
772 <Text key={`count-${c.kind}`} color={color[c.kind]}>{`${i > 0 ? ' · ' : ''}${c.n} ${c.kind}`}</Text>
773 ))}
774 </Box>
775 {dg === undefined ? (
776 out
777 ) : (
778 <Box key="diagram" flexDirection="column">
779 {dg.rows.map((runs, y) => (
780 <Box key={`dg-${y}`} flexDirection="row">
781 {runs.map((r, i) => (
782 <Box key={`dg-${y}-${i}`}>
783 <Text color={r.color}>{r.text}</Text>
784 </Box>
785 ))}
786 </Box>
787 ))}
788 {dg.isCut && (
789 <Box key="dg-cut">
790 <Text color={C.muted}>wider than the pane: drag it wider</Text>
791 </Box>
792 )}
793 </Box>
794 )}
795 {dg === undefined && isStarred && (
796 <Box key="footnote">
797 <Text color={C.muted}>* model set by mod: click the row for why</Text>
798 </Box>
799 )}
800 {dg === undefined && isSkipped && (
801 <Box key="skipnote">
802 <Text color={C.soon}>✉ yellow: sent to main, not to its parent</Text>
803 </Box>
804 )}
805 {dg === undefined && isQuiet && (
806 <Box key="quietnote">
807 <Text color={C.soon}>{`└ yellow: no tool call or return for ${clock(QUIET_MS)}`}</Text>
808 </Box>
809 )}
810 <Box key="footer" flexDirection="column">
811 <Box key="keys" flexDirection="row">
812 <Button key="hide" plain hotkey="h" onPress={() => void update($, view, w => ({ ...w, hideDone: !w.hideDone }))}>
813 <Text color={C.text}>{v.hideDone ? 'show done' : 'hide done'}</Text>
814 </Button>
815 <Text> </Text>
816 <Button key="clear" plain hotkey="c" onPress={clear}>
817 <Text color={C.text}>clear done</Text>
818 </Button>
819 <Text> </Text>
820 <Button key="view" plain hotkey="v" onPress={() => void update($, view, w => ({ ...w, diagram: w.diagram !== true }))}>
821 <Text color={C.text}>{v.diagram === true ? 'list' : 'diagram'}</Text>
822 </Button>
823 </Box>
824 <Box key="keys2" flexDirection="row">
825 <Button
826 key="motion"
827 plain
828 hotkey="m"
829 onPress={() => void update($, view, w => ({ ...w, still: !w.still })).then(() => animate($))}
830 >
831 <Text color={C.text}>{v.still ? 'motion on' : 'motion off'}</Text>
832 </Button>
833 <Text> </Text>
834 {/* The relay mod answers /relay now: a handoff at the next breakpoint, /clear, continue. */}
835 <Button
836 key="handoff"
837 plain
838 hotkey="r"
839 onPress={() => void $.command.run({ command: 'relay', args: 'now' }).catch(err => $.ui.toast(`handoff: ${String(err)}`))}
840 >
841 <Text color={C.text}>handoff</Text>
842 </Button>
843 <Text color={C.muted}> esc close</Text>
844 </Box>
845 {pastN > 0 && (
846 <Box key="keys3" flexDirection="row">
847 <Button key="past" plain hotkey="p" onPress={() => void update($, view, w => ({ ...w, hidePast: w.hidePast !== true })).then(() => show($))}>
848 <Text color={C.text}>{v.hidePast === true ? 'show past' : 'hide past'}</Text>
849 </Button>
850 <Text color={C.muted}>{` ${pastN} from before /clear`}</Text>
851 </Box>
852 )}
853 </Box>
854 </Box>
855 )
856 })
857}
858hooks/diagram.ts 136 lines1// The box diagram of the agent tree: main on the left, each agent's children in a column to its right, joined by
2// lines; a lane's heading sits over its first box. No engine here, so the tests lay it out directly. A line toward a working agent pulses away from its
3// parent, one cell a frame, so the motion runs toward the subagents; motion off, the live path holds lit.
4
5// `meta`: the line under the name, model and time. `title`: a heading over the box, as a lane's first agent has.
6// `edge`: the color of the box's border and its heading, a lane's color; else the lines' color.
7export type Node = { label: string; glyph: string; color: string; meta: string; isLive: boolean; kids: Node[]; title?: string; edge?: string }
8export type Run = { text: string; color: string }
9
10const BOX_MAX = 24
11const BOX_MIN = 12
12// A box: its borders around three lines, the name in up to two and the meta under it.
13const BOX_H = 5
14// The row of a box where its lines join.
15const MID = Math.floor(BOX_H / 2)
16// Rows between sibling boxes.
17const GAP = 1
18// Columns between a box and its children: the stub, the bus and the lead.
19const LINK = 3
20// One lit cell in every PULSE cells of a live line.
21const PULSE = 4
22// A line cell's arms as bits: up 8, down 4, left 2, right 1.
23const JOIN: Record<number, string> = { 12: '│', 3: '─', 5: '┌', 6: '┐', 9: '└', 10: '┘', 7: '┬', 11: '┴', 13: '├', 14: '┤', 15: '┼' }
24const HEAVY: Record<string, string> = { '─': '━', '│': '┃' }
25
26// A line cell keeps its distance from the parent and whether a working agent lies beyond it.
27type Cell = { ch: string; color: string; d?: number; isLive?: boolean }
28
29export const fit = (v: string, w: number) => (v.length <= w ? v : `${v.slice(0, Math.max(0, w - 1))}…`)
30
31// The label in one line of w, else two, broken at the last space that fits; the rest is cut.
32function lines(label: string, w: number): string[] {
33 if (label.length <= w) {
34 return [label]
35 }
36 const at = label.lastIndexOf(' ', w)
37 return at > 0 ? [label.slice(0, at), fit(label.slice(at + 1), w)] : [label.slice(0, w), fit(label.slice(w), w)]
38}
39
40// The rows over n's box: the headings over its first child, its first grandchild and so on, so n sits level with them.
41const lift = (n: Node): number => (n.kids[0] === undefined ? 0 : (n.kids[0].title === undefined ? 0 : 1) + lift(n.kids[0]))
42// A child's rows: its heading, then its subtree.
43const slot = (k: Node): number => (k.title === undefined ? 0 : 1) + span(k)
44const span = (n: Node): number => (n.kids.length === 0 ? BOX_H : n.kids.reduce((s, k) => s + slot(k) + GAP, -GAP))
45const depth = (n: Node): number => (n.kids.length === 0 ? 0 : 1 + Math.max(...n.kids.map(depth)))
46const isLive = (n: Node): boolean => n.isLive || n.kids.some(isLive)
47
48// The diagram's rows as color runs, centered in `width`. `frame` moves the pulse; undefined holds it.
49export function diagram(root: Node, width: number, frame: number | undefined, line: string, pulse: string) {
50 // The widest boxes that fit; at the narrowest, the rows cut at the pane's edge.
51 const d = depth(root)
52 const bw = Math.max(BOX_MIN, Math.min(BOX_MAX, Math.floor((width - d * LINK) / (d + 1))))
53 const gridW = (d + 1) * bw + d * LINK
54 const grid: Cell[][] = Array.from({ length: span(root) }, () => Array.from({ length: gridW }, () => ({ ch: ' ', color: line })))
55 const put = (x: number, y: number, cell: Cell) => {
56 grid[y]![x] = cell
57 }
58 const write = (x: number, y: number, s: string, color: string) => [...s].forEach((ch, i) => put(x + i, y, { ch, color }))
59
60 // Draws n's subtree from row y0, its box at row y, level with its first child's. `base` is the distance of the joint
61 // on n's right border, so one pulse flows on from main through each box to its children.
62 const place = (n: Node, x: number, y0: number, base: number) => {
63 const y = y0 + lift(n)
64 const [first = '', second] = lines(n.label, bw - 6)
65 const body: [string, string][] = [[` ${n.glyph} ${first}`, n.color]]
66 if (second !== undefined) {
67 body.push([` ${second}`, n.color])
68 }
69 body.push([` ${fit(n.meta, bw - 6)}`, line])
70 const edge = n.edge ?? line
71 write(x, y, `╭${'─'.repeat(bw - 2)}╮`, edge)
72 for (let i = 0; i < BOX_H - 2; i += 1) {
73 const [text, color] = body[i] ?? ['', line]
74 write(x, y + 1 + i, '│', edge)
75 write(x + 1, y + 1 + i, text.padEnd(bw - 2), color)
76 write(x + bw - 1, y + 1 + i, '│', edge)
77 }
78 write(x, y + BOX_H - 1, `╰${'─'.repeat(bw - 2)}╯`, edge)
79 if (n.kids.length === 0) {
80 return
81 }
82
83 const r = x + bw - 1
84 const my = y + MID
85 let ky = y0
86 const ends = n.kids.map(k => {
87 if (k.title !== undefined) {
88 write(r + LINK + 1, ky, fit(k.title, gridW - r - LINK - 1), k.edge ?? line)
89 ky += 1
90 }
91 const at = ky + lift(k) + MID
92 place(k, r + LINK + 1, ky, base + LINK + bw + (at - my))
93 ky += span(k) + GAP
94 return { at, isLive: isLive(k) }
95 })
96 const anyLive = ends.some(e => e.isLive)
97 put(r, my, { ch: '├', color: line, d: base, isLive: anyLive })
98 put(r + 1, my, { ch: '─', color: line, d: base + 1, isLive: anyLive })
99 const hi = Math.max(...ends.map(e => e.at))
100 for (let yy = my; yy <= hi; yy += 1) {
101 const arms = (yy > my ? 8 : 0) | (yy < hi ? 4 : 0) | (yy === my ? 2 : 0) | (ends.some(e => e.at === yy) ? 1 : 0)
102 // A bus cell leads to each child at or under it.
103 const live = ends.some(e => e.isLive && e.at >= yy)
104 put(r + 2, yy, { ch: JOIN[arms] ?? '│', color: line, d: base + 2 + (yy - my), isLive: live })
105 }
106 for (const e of ends) {
107 put(r + 3, e.at, { ch: '─', color: line, d: base + 3 + (e.at - my), isLive: e.isLive })
108 put(r + 4, e.at, { ch: '┤', color: line, d: base + 4 + (e.at - my), isLive: e.isLive })
109 }
110 }
111 // Main's stub is distance 0.
112 place(root, 0, 0, -1)
113
114 const isLit = (cell: Cell) =>
115 cell.isLive === true && cell.d !== undefined && (frame === undefined || (((cell.d - frame) % PULSE) + PULSE) % PULSE === 0)
116 const isCut = gridW > width
117 const pad = isCut ? 0 : Math.floor((width - gridW) / 2)
118 const rows = grid.map(row => {
119 const cells = isCut && row.slice(width - 1).some(c => c.ch !== ' ') ? [...row.slice(0, width - 1), { ch: '…', color: line }] : row.slice(0, width)
120 const runs: Run[] = pad > 0 ? [{ text: ' '.repeat(pad), color: line }] : []
121 for (const cell of cells) {
122 const ch = isLit(cell) ? (HEAVY[cell.ch] ?? cell.ch) : cell.ch
123 const color = isLit(cell) ? pulse : cell.color
124 const last = runs.at(-1)
125 if (last?.color === color) {
126 last.text += ch
127 } else {
128 runs.push({ text: ch, color })
129 }
130 }
131 return runs
132 })
133
134 return { rows, isCut }
135}
136hooks/route.ts 51 lines1// Jev picks the model of a subagent that names none: one Choice question over three tiers.
2// No engine here, so evals/route.eval.ts asks Jev the same question the mod asks.
3
4export const JEV_URL = 'https://openrouter.ai/api/v1/systemone'
5// Pinned, so an eval run and the live mod ask the same model.
6export const JEV_MODEL = 'typesafe/jev-1.13'
7// Under this confidence the mod keeps its own rule. The routing eval prints accuracy per confidence band.
8export const MIN_CONFIDENCE = 0.5
9// Jev reads worse as unrelated state grows (its docs, "jaggedness"): the head of a prompt states the task.
10const PROMPT_CHARS = 4000
11
12export type Tier = 'haiku' | 'sonnet' | 'opus'
13export type Pick = { tier: Tier; confidence: number }
14
15// "Model economy" in ~/.claude/CLAUDE.md, one rubric line per tier.
16export const TIERS: Record<Tier, string> = {
17 haiku:
18 'Read-heavy work where only the conclusion matters: look something up, list or search files, search the web, read logs or docs, run a fixed command and report. No design and no code edits.',
19 sonnet:
20 'Needs judgment: review or compare code, debug a failure, research with trade-offs, write or edit a contained piece of code, follow a written plan.',
21 opus: 'Hard, open-ended reasoning: design an architecture, write a plan or a spec, build a large feature across many files, find a subtle root cause.',
22}
23
24export type Task = { subagentType: string; description: string; prompt: string }
25
26// Jev leans toward the first option (its docs, "Choice option order"): the eval asks again in reverse.
27export function question(t: Task, order: Tier[] = ['haiku', 'sonnet', 'opus']) {
28 return {
29 model: JEV_MODEL,
30 state: { agent_type: t.subagentType, description: t.description, task: t.prompt.slice(0, PROMPT_CHARS) },
31 questions: {
32 tier: {
33 type: 'choice',
34 instructions: 'Which is the cheapest model tier that can do `task` well?',
35 criteria: Object.fromEntries(order.map(tier => [tier, TIERS[tier]])),
36 },
37 },
38 }
39}
40
41// The tier Jev chose, or undefined for a reply that is not a usable answer.
42export function pick(body: string): Pick | undefined {
43 try {
44 const a = JSON.parse(body)?.answers?.tier
45 const tier = typeof a?.choice === 'string' && a.choice in TIERS ? (a.choice as Tier) : undefined
46 return tier !== undefined && typeof a.confidence === 'number' ? { tier, confidence: a.confidence } : undefined
47 } catch {
48 return undefined
49 }
50}
51types/index.d.ts 52 lines1// The state contract of the agent-board mod: one record per agent loop,
2// keyed by agentId, the main loop under 'main', and the board's own view.
3
4// One recent tool call of an agent, by its tool_use_id: when it started, and when it returned.
5export type Call = { id: string; label: string; isError?: boolean; at?: number; end?: number }
6
7export type AgentStat = {
8 model?: string
9 // The mod set the model: Jev's pick, or its own rule when Jev did not answer.
10 isStar?: boolean
11 // Who picked the model and why, as the expanded row shows it: "jev haiku at 0.93", "haiku, jev: timeout".
12 route?: string
13 tools: number
14 fails: number
15 tool?: string
16 last: Call[]
17 at: number
18 isDone: boolean
19 report?: number
20 // New tokens (input, output, cache writes) apart from cache reads, which cost a tenth.
21 tokens: number
22 cached: number
23 // Time of the finished turns, plus the start of the one that runs now.
24 ms: number
25 since?: number
26 // When the board first saw the agent run: its spawn, else its first tool call. The time lanes start here.
27 start?: number
28 // turn.complete's reason; the contract may not import, so the union is spelled here.
29 end?: 'answer' | 'aborted' | 'refusal' | 'error'
30 // When a tool call failed or the agent returned: the row flashes for a second.
31 flashAt?: number
32 // The agent sent a message to main: its row shows ✉, yellow when the agent is nested and so skipped its parent.
33 isToMain?: boolean
34 // The git branch of an agent that runs in a worktree or a directory other than the session's: its lane's heading shows it.
35 branch?: string
36 // The agent's list entry, kept so its row outlives the engine's task.
37 info?: { description: string; type: string; name?: string; parentId?: string }
38 // The agent ended before a /clear: the board keeps its row as a record, `p` hides it.
39 isPast?: boolean
40}
41
42// Which rows are expanded, whether returned agents hide, whether motion is off, which returned agents `c` cleared,
43// whether `v` shows the box diagram instead of the rows, and whether `p` hides the rows from before a /clear.
44// `cleared`, `diagram` and `hidePast` are absent from an older save.
45export type BoardView = { open: string[]; hideDone: boolean; still: boolean; cleared?: string[]; diagram?: boolean; hidePast?: boolean }
46
47declare module 'claude-code' {
48 interface PluginState {
49 'agent-board': { agents: Record<string, AgentStat>; view: BoardView }
50 }
51}
52