A live task HUD for the Code tab of Claude Desktop: a strip above the prompt tracks progress, phases and key numbers while Claude works, with a full dashboard…

A live progress strip above the prompt, and a dashboard beside the conversation, for the long tasks Claude Code works on. It runs in the Claude Desktop app's Code tab and in the terminal.
<img src="docs/img/band-prompt.png" width="780" alt="The strip above the prompt during a rollout: an urgent line saying tests that were passing now fail, 7 of 12 hosts, requests per second down 830, p95 latency 231 ms, the Rollout phase, and the last log line about a failed health check">
While Claude builds, migrates, refactors or retries, the strip shows the run's title and goal, its progress, the figures that matter and the last thing that happened. Expand opens the full dashboard. You don't have to ask for it: the HUD reads Claude's tool calls for what it can see on its own, starts a run when a message turns into a long task, and keeps the board honest while Claude works. Claude adds only what it alone knows: the title, the goal, the phases, and done or failed.
In Claude Code:
/plugin install hud --marketplace kyledarling-io/claude-code-desktop-hud
In the Claude Desktop app: Customize → Plugins → Add → Add marketplace → Add from a repository, paste https://github.com/kyledarling-io/claude-code-desktop-hud, sync, then install hud from the marketplace. Start a Code session and the strip appears when Claude starts a long task, or say "show me a HUD while you do this."
Needs Claude Code 2.1.295 or later. VS Code and the mobile app don't draw the strip yet.
A rollout to twelve hosts where one fails its health check.
| <img src="docs/img/strip-1-start.png" width="400" alt="The strip at the start: 0 of 12 hosts, requests per second, p95 latency, the Build phase of four"> | Start. Claude names the run, its goal and its phases. Progress, figures and the phase rail take the strip. |
| <img src="docs/img/strip-2-canary.png" width="400" alt="The strip mid-run: 4 of 12 hosts, requests per second up 950, p95 latency up 7 ms, the Canary phase with one done"> | Mid-run. Figures carry a change and a sparkline, green or red once Claude says which way is good. |
| <img src="docs/img/strip-3-failure.png" width="400" alt="The strip after a failure: an Urgent line, tests 2 failed were passing, 7 of 12 hosts, requests down, latency up, the Rollout phase"> | Something breaks. Tests that were passing now fail. The HUD saw it in the test runner's own output and put it first, ahead of everything Claude sent. |
<img src="docs/img/dashboard-failure.png" width="360" alt="The dashboard pane for the same rollout: progress, the phase rail, the host grid with one cross, counts, the tests figure marked urgent, figures with a line under each saying why it is shown, a checklist, the HUD's own context and cost figures, the log, the Showing block with the mode, and the Sources list">
The dashboard shows everything on the board. Under each figure is a line saying why it is there: Urgent, Claude's focus, Build mode, Changed 2 calls ago · warn, or the name of the plugin that sent it. At the bottom, Showing names your mode, pins and hides, and Sources lists every plugin feeding the board and what each one reads.
Progress, a phase rail, figures with deltas and sparklines, a grid of hosts or files, a trend line, counts, a countdown, a log, and gauge, ring, meter, line, bars, columns, donut, heatmap, ranked list, checklist, table and callout. A training run with most of them in use:
<img src="docs/img/strip-4-training.png" width="780" alt="The strip for a training run: 4 of 10 epochs, a validation accuracy gauge at 91.2%, the Train phase of four, and the last log line">
<img src="docs/img/window-training.png" width="900" alt="The Claude Desktop window with the conversation blurred, the one-line strip above the prompt, and the dashboard pane on the right showing the training run: progress, phases, a gauge, a loss line, meters and figures">
| Command | Does | ||||
|---|---|---|---|---|---|
/hud | Brings back a hidden strip and prints the run, with why each figure is shown | ||||
| `/hud mode <build\ | debug\ | review\ | research\ | plan>` | Sets which figures come first. Yours outranks Claude's. |
/hud pin <slot>, /hud hide <slot> | Keeps a figure first, or leaves it out, in this session and the next | ||||
/hud sources, /hud mute <name> | Lists the plugins feeding the board, or silences one |
In the terminal the band above the prompt is text: the same figures, in the same order, each with why it is there.
<img src="docs/img/terminal-band.png" width="600" alt="The terminal band above the prompt: the run's title, progress, the figures as text with why each is shown, and the last log line">
Any plugin can send the HUD figures of its own through $.hud: a deploy's state, a queue's depth, the last build. The HUD checks what it gets, ranks it with everything else and shows the sender's name beside it. Two sources ship here, each its own install: hud-git (branch, files changed, ahead and behind) and hud-ci (your branch's GitHub Actions run). SOURCES.md is the guide to writing one, and examples/source-template is a working source with its test.
The HUD reads no files, makes no network requests, starts no processes and calls no model. It reads Claude's tool calls for names and counts (a command's name, a file's path, the pass and fail counts a test runner prints), never file contents, and nothing else from a command's output. Claude gets one cached section of its system prompt and a one-line note beside your messages during a run. Nothing another plugin sends reaches Claude. claude plugin validate . lists every hook it handles and every call it makes.
See DEVELOPING.md. Run the tests with claude plugin test ..
Created by Kyle Darling. Built with Claude Code. MIT License.
hooks/register.tsx 1090 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Hud, HudRun } from '../types'
5import { agentSaid, said, write, type Said } from './autolog'
6import { MEASURE, measureText, observe as seeBound, planText, unread } from './bind'
7import { inOrder, isMuted, slug, sourcesOf, typed, whose } from './board'
8import { boundary, DRAWS, jobsRunning, life, notification, PAUSED, pauses, quietFor, shouldStart, titleOf, WAITING } from './lifecycle'
9import { FOCUS_MAX, focusOf, MODE_NAMES, modeCommand, modeLine, modeOf, MODES } from './modes'
10import { autoNote, isBound, KICKOFF, nudge, pointed, pointedIds, stale, steer } from './notes'
11import { intoRun, mirror, TOOLS as PLAN_TOOLS, type PlanItem } from './plan'
12import { keyList, keysOf, NO_PREFS, prefsCommand, prefsLines, prefsOf } from './prefs'
13import { claudeSlots, nextArrangement, rank, unhidden, urgentFirst, type Placed } from './rank'
14import { agoOf, alarmLine, BOLD, bandLines, blockLine, clock, COLOR, cutText, entryOf, factsOf, figuresOf, fitText, headerOf, jobsText, lastLine, len, NBSP, span, spark } from './render'
15import { adopts, apply, asList, fresh, isLive, type Input } from './run'
16import { SKILL } from './skill'
17import * as agents from './sources/agents'
18import { accept, acceptDeclaration, type Declaration, type Pushes } from './sources/contract'
19import * as meter from './sources/meter'
20import * as testRuns from './sources/tests'
21import * as toolSignals from './sources/tools'
22import type { Call } from './sources/tools'
23import { dashboard, describe, fit, fmt, measure, plain, sentence, strip, stripFocus, stripPicked, stripSlots, wrapPx } from './svg'
24import { answer, DESCRIPTION, SCHEMA } from './tool'
25import { urgentFor, urgentNow, type Urgent } from './urgency'
26
27const PANE = 'hud'
28const TOOL = 'mcp__hud__hud'
29const run = atom({ plugin: 'hud', key: 'run' } as const, null)
30const paneOpen = atom({ plugin: 'hud', key: 'paneOpen' } as const, false)
31const stripHidden = atom({ plugin: 'hud', key: 'stripHidden' } as const, false)
32// The sources' slots and declarations live in the session's state, not module memory: a reload resets the module.
33const slots = atom({ plugin: 'hud', key: 'slots' } as const, {})
34const sources = atom({ plugin: 'hud', key: 'sources' } as const, {})
35// The main loop's tool calls this session, live run or not (a hud call isn't one): a slot's and a widget's `n` (when it
36// changed) count in these.
37const calls = atom({ plugin: 'hud', key: 'calls' } as const, 0)
38const callsNow = async ($: EngineInterface) => (await read($, calls)) ?? 0
39
40// $.hud's door. `from` is the host's word for the sender (`next.origin.plugin`); what it sends is
41// data that passed the gate, never proof: another plugin may have changed it on the way. The HUD's own sources
42// call these directly.
43type Answer<M extends keyof Hud> = Awaited<ReturnType<Hud[M]>>
44export async function declare($: EngineInterface, from: string, raw: unknown): Promise<Answer<'declare'>> {
45 const d = acceptDeclaration(raw)
46 if (typeof d === 'string') return { ok: false, why: d }
47 await update($, sources, all => ({ ...all, [from]: d }))
48 // a slot no longer declared, or declared another pattern, goes with the old declaration
49 await update($, slots, all => Object.fromEntries(Object.entries(all).filter(([, s]) => s.from !== from || d.slots.some(x => x.id === s.id && x.pattern === s.pattern))))
50 return { ok: true, v: 1 }
51}
52
53const hold = async ($: EngineInterface, from: string, decl: Declaration | undefined, raw: unknown): Promise<Answer<'push'>> => {
54 const h = accept(raw, from, decl, await $.clock.now())
55 if (typeof h === 'string') return { ok: false, why: h }
56 const n = await callsNow($)
57 await update($, slots, all => ({ ...all, [`${from}/${h.id}`]: { ...h, n } }))
58 return { ok: true }
59}
60
61export async function push($: EngineInterface, from: string, raw: unknown): Promise<Answer<'push'>> {
62 return hold($, from, (await read($, sources))[from], raw)
63}
64
65export async function clear($: EngineInterface, from: string, raw: unknown): Promise<Answer<'clear'>> {
66 const gone = `${from}/${String((raw as { id?: unknown } | null)?.id)}`
67 await update($, slots, all => Object.fromEntries(Object.entries(all).filter(([k]) => k !== gone)))
68 return { ok: true }
69}
70
71// The noun's own answers, reached only when the HUD's hook on the call failed: the gate runs in those hooks, where
72// the host names the sender.
73const UNHEARD = 'the HUD could not take it'
74const HUD: Hud = {
75 declare: async () => ({ ok: false, why: UNHEARD }),
76 push: async () => ({ ok: false, why: UNHEARD }),
77 clear: async () => ({ ok: false, why: UNHEARD }),
78}
79
80// The HUD's own sources (hooks/sources/), written as if they were other plugins. Their slots
81// pass the same gate, under the HUD's own name, against their own declarations: the HUD's call on its own noun isn't
82// proved to reach its own hook, so they call the gate directly. Their memory lives in $.state, as a source's must.
83const toolsSeen = atom({ plugin: 'hud', key: 'toolSignals' } as const, toolSignals.init)
84const testsSeen = atom({ plugin: 'hud', key: 'testResults' } as const, testRuns.init)
85const agentsSeen = atom({ plugin: 'hud', key: 'agents' } as const, agents.init)
86const feed = async ($: EngineInterface, decl: Declaration, p: Pushes) => {
87 for (const s of p.push) await hold($, 'hud', decl, s)
88 for (const id of p.clear) await clear($, 'hud', { id })
89}
90
91// A mute is kept by key across sessions in $.store, mirrored in $.state for drawing. A muted
92// source's slots are still gated and held, so unmuting is instant, but they're never drawn.
93const muted = atom({ plugin: 'hud', key: 'muted' } as const, [])
94// The slots shown: not a muted sender's, and not one the person hid (their word outranks the urgency rules, so a hidden
95// slot raises no urgency and draws nowhere). A slot is still held and gated either way, so unmuting or unhiding is instant.
96const heldNow = async ($: EngineInterface) => {
97 const decls = await read($, sources), off = await read($, muted), list = sourcesOf(decls), hidden = (await prefsNow($)).hidden
98 return inOrder(await read($, slots), decls).filter(s => !isMuted(s, list, off) && !hidden.includes(`${s.from}/${s.id}`))
99}
100
101// The urgency place (hooks/urgency.ts): the HUD's own rules over the slots shown, weighed after anything that changes a
102// slot and at every boundary, and kept in $.state so a reload keeps it. A boundary is what counts in run.boundaries.
103// `forget` drops the standing one first: a mute silencing it, or a main-loop turn's end with no run to count a boundary.
104// It rides calls that must go on, so a fault in it is swallowed: the place keeps what it had and the next weighing tries again.
105// simplification: `held` is read before update's retry, so two weighings finishing together may land in either order; the next one corrects it
106const urgent = atom({ plugin: 'hud', key: 'urgent' } as const, null)
107const weighUrgency = async ($: EngineInterface, forget?: (u: Urgent) => boolean) => {
108 try {
109 const boundaries = (await read($, run))?.boundaries ?? 0, held = await heldNow($)
110 await update($, urgent, prev => urgentNow(held, prev && !forget?.(prev) ? prev : undefined, boundaries) ?? null)
111 } catch { /* a fault in the urgency place costs nothing else */ }
112}
113// A boundary the arrangement adds to the run's (a hud call's mode, focus or strip; the person's /hud commands) counts
114// in run.boundaries as a phase does, once: stale widgets retire and an urgency hold ends on it too. With no live run
115// nothing counts, as at a turn's end; the urgency is weighed either way.
116const crossed = async ($: EngineInterface, forget?: (u: Urgent) => boolean) => {
117 if (isLive(await read($, run))) {
118 const now = await $.clock.now()
119 await update($, run, r => (isLive(r) ? boundary(r, now) : r))
120 }
121 await weighUrgency($, forget)
122}
123const setMuted = async ($: EngineInterface, key: string, on: boolean) => {
124 let now: string[] = [] // set inside update's closure
125 await update($, muted, m => (now = on ? [...new Set([...m, key])] : m.filter(k => k !== key)))
126 await $.store.set('muted', now)
127}
128
129const trust = async ($: EngineInterface, sub: string, name: string): Promise<string> => {
130 const list = sourcesOf(await read($, sources)), off = await read($, muted)
131 if (sub === 'sources') {
132 return [...list.map(s => `${s.name} · ${whose(s)}${off.includes(s.key) ? ' · muted' : ''}\n ${s.access}\n ${s.slots.join(', ')}`),
133 'Hide one with /hud mute <name>, such as /hud mute ' + typed(list.at(-1)!) + '.'].join('\n')
134 }
135 const s = list.find(x => name && [x.key, typed(x)].includes(slug(name)))
136 if (!s) return `No source "${name}". Name one of: ${list.map(typed).join(', ')}.`
137 await setMuted($, s.key, sub === 'mute')
138 // a mute is the person's word, not a boundary to wait for: what it silenced leaves the urgency place at once, and
139 // an unmuted source's rule is weighed again
140 const ids = s.ids.map(id => `${s.from}/${id}`)
141 await crossed($, u => sub === 'mute' && ids.includes(u.key))
142 await arrange($, sub === 'mute' ? [] : ids) // brought back, its slots don't wait
143 return sub === 'mute' ? `${s.name} is muted: its figures are hidden until /hud unmute ${typed(s)}.` : `${s.name} is back.`
144}
145
146// What shows first, kept in $.state for the ranker to read. The person's word is the mode they
147// set with /hud mode (it holds for the session) and their pins and hides (kept by key across sessions in $.store,
148// mirrored here for drawing); Claude can only say its mode (while the person has not set one) and its focus.
149const mode = atom({ plugin: 'hud', key: 'mode' } as const, 'build')
150const modeByPerson = atom({ plugin: 'hud', key: 'modeByPerson' } as const, false)
151const focus = atom({ plugin: 'hud', key: 'focus' } as const, [])
152const prefs = atom({ plugin: 'hud', key: 'prefs' } as const, NO_PREFS)
153
154// A state older than these keys has none of them: build, nobody's, none, none.
155const modeNow = async ($: EngineInterface) => (await read($, mode)) ?? 'build'
156const personsMode = async ($: EngineInterface) => (await read($, modeByPerson)) === true
157const focusNow = async ($: EngineInterface) => (await read($, focus)) ?? []
158const prefsNow = async ($: EngineInterface) => (await read($, prefs)) ?? NO_PREFS
159
160// The keys a person or Claude may name: every slot held, and the run's widgets.
161const knownKeys = async ($: EngineInterface) => keysOf(await read($, run), await read($, slots))
162
163// /hud mode, pin, unpin, hide and unhide, each answering with what it did.
164const control = async ($: EngineInterface, sub: string, arg: string): Promise<string> => {
165 if (sub === 'mode') {
166 const said = modeCommand(arg, await modeNow($), await personsMode($))
167 const chosen = said.mode
168 if (chosen) {
169 await update($, mode, () => chosen)
170 await update($, modeByPerson, () => true)
171 await crossed($) // a boundary: counted, the urgency weighed and the strip decided again
172 await arrange($)
173 }
174 return said.text
175 }
176 // simplification: read, decide, write; only the person's typing writes prefs, one command at a time
177 const held = await prefsNow($), said = prefsCommand(held, sub as 'pin' | 'unpin' | 'hide' | 'unhide', arg, await knownKeys($))
178 if (said.prefs !== held) {
179 await update($, prefs, () => said.prefs)
180 await $.store.set('prefs', said.prefs)
181 // a boundary; a slot the person unhid comes back at once (a pin never waits)
182 await crossed($)
183 await arrange($, held.hidden.filter(k => !said.prefs.hidden.includes(k)))
184 }
185 return said.text
186}
187
188// The lines /hud and the pane show of that: the mode, and what the person pinned or hid.
189const showing = async ($: EngineInterface): Promise<string[]> =>
190 [modeLine(await modeNow($), await personsMode($)), ...prefsLines(await prefsNow($))]
191
192// Who decides what's shown (hooks/rank.ts): the mode, Claude's focus, the person's pins and hides
193// as above, and the urgency place (above), read as they stand.
194const arrangement = atom({ plugin: 'hud', key: 'arrangement' } as const, undefined)
195// The board ranked: Claude's widgets beside every unmuted slot, the pane's order and why each is there. Claude's
196// `strip` counts as its focus, ahead of `focus`.
197const board = async ($: EngineInterface, r: HudRun | null): Promise<Placed[]> => {
198 const all = [...claudeSlots(r), ...(await heldNow($))], at = new Map(all.map((s, i) => [s, i]))
199 const placed = rank(all, {
200 mode: await modeNow($), focus: [...stripFocus(r), ...(await focusNow($))], prefs: await prefsNow($),
201 calls: await callsNow($), order: s => at.get(s) ?? 0,
202 })
203 return urgentFirst(placed, await read($, urgent))
204}
205// One view of the run for what is ranked, arranged, printed and drawn: a bound widget with no reading anyone stands
206// behind says "no reading" (no old number, no old tone; it ages from its last reading), and what the person hid never
207// shows, on any surface (the run without those widgets; the slots without them are heldNow()'s).
208const drawable = async ($: EngineInterface) => {
209 const hidden = (await prefsNow($)).hidden
210 return { run: (r: HudRun) => unhidden(unread(r), hidden), held: () => heldNow($) }
211}
212// A boundary (a main-loop turn's end; a hud call with start, phase, rebuild, mode, focus or strip; the person's mute,
213// unmute or a /hud command that changed something): the strip is decided again and kept until the next, with the room
214// it gave the run's own items. `back`: keys the person brought back, which never wait.
215// simplification: the board is ranked before update's closure (ranking reads $), so a pin landing between the two can be lost
216// from the strip until the next boundary
217const arrange = async ($: EngineInterface, back: string[] = []) => {
218 const raw = await read($, run)
219 if (raw === null) return
220 const r = (await drawable($)).run(raw)
221 const u = await read($, urgent), placed = (await board($, r)).filter(p => p.key !== u?.key) // the urgency place is its own
222 const now = await $.clock.now()
223 await update($, arrangement, prev => {
224 let frame: string[] = [] // set by the pick
225 const next = nextArrangement(prev, placed, c => { const p = stripSlots(r, c); frame = p.frame; return p.slots }, now, back)
226 return { ...next, frame }
227 })
228}
229
230// What Claude says of its work, which is not the run: its `mode` (applied only while the person has not set one) and
231// its `focus` (up to 3 keys, kept as sent: a slot the HUD's own source has not raised yet may come). A new run starts
232// from none of Claude's. Returns the lines after the tool's `ok`: a mode or id it could not take, named back (ids and
233// modes only, never a slot's value or note).
234async function intend($: EngineInterface, i: Input): Promise<string[]> {
235 const notes: string[] = [], byPerson = await personsMode($)
236 if (i.start !== undefined) {
237 if ((await focusNow($)).length) await update($, focus, () => [])
238 if (!byPerson && (await modeNow($)) !== 'build') await update($, mode, () => 'build')
239 }
240 if (i.mode !== undefined) {
241 const sent = modeOf(i.mode), now = await modeNow($)
242 if (!sent) notes.push(`mode: unknown "${String(i.mode).slice(0, 20)}" (modes: ${MODE_NAMES.join(', ')})`)
243 else if (!byPerson) await update($, mode, () => sent)
244 else if (sent !== now) notes.push(`mode: the person chose ${MODES[now].label}, so yours was not applied`)
245 }
246 if (i.focus !== undefined) {
247 const sent = focusOf(asList(i.focus)), kept = sent.slice(0, FOCUS_MAX)
248 // only a real empty list clears it; junk changes nothing
249 const clears = !sent.length && asList(i.focus).length === 0 && (Array.isArray(i.focus) ? i.focus.length === 0 : String(i.focus).trim() === '[]')
250 if (sent.length || clears) {
251 await update($, focus, () => kept)
252 if (sent.length > kept.length) notes.push(`focus: only ${FOCUS_MAX} count; kept ${kept.join(', ')}`)
253 const known = await knownKeys($), unknown = kept.filter(k => !known.includes(k))
254 if (unknown.length) notes.push(`focus: unknown ${unknown.join(', ')} (known: ${keyList(known)})`)
255 }
256 }
257 return notes
258}
259
260// simplification: read, observe, write with update's retry; two calls finishing together may push in either order
261// Answers what the tests source saw, for the auto-log: its last failed count before this call, and the counts of the
262// summary it took from it (none, when it took none).
263const observeCall = async ($: EngineInterface, c: Call) => {
264 let t: Pushes = { push: [], clear: [] }, r: Pushes = t // set inside update's closure
265 const tests: { before?: number; after?: ReturnType<typeof testRuns.counts> } = {} // set inside update's closure
266 await update($, toolsSeen, s => { const o = toolSignals.observe(s, c); t = o; return o.state })
267 await update($, testsSeen, s => {
268 const o = testRuns.observe(s, c)
269 r = o
270 tests.before = s.failed.at(-1)
271 tests.after = o.push.length ? testRuns.counts(c.text ?? '') : undefined
272 return o.state
273 })
274 await feed($, toolSignals.declaration, t)
275 await feed($, testRuns.declaration, r)
276 return tests
277}
278
279// The auto-log's write (hooks/autolog.ts): a line goes in only while a run is live. The observer's lines are not
280// Claude's, so they leave updatedAt, sinceUpdate and the nudge alone.
281const logObserved = async ($: EngineInterface, lines: Said[]) => {
282 if (!lines.length) return
283 const now = await $.clock.now()
284 await update($, run, r => (isLive(r) ? { ...r, log: lines.reduce((log, s) => write(log, s, now), r.log) } : r))
285}
286
287// The plan mirror (hooks/plan.ts): a finished task-list call of the main loop updates the plan, which a live run shows.
288// The plan is held in $.state whether or not a run is live, as TaskUpdate names an item the list already holds.
289// A run that starts on its own, or that Claude adopts, takes the held plan at once (autoRun(), the hud call).
290// simplification: a run Claude starts fresh takes it only at the next list call
291const plan = atom({ plugin: 'hud', key: 'plan' } as const, [])
292const mirrorPlan = async ($: EngineInterface, tool: string, input: Record<string, unknown>, result: unknown) => {
293 if (!PLAN_TOOLS.includes(tool)) return
294 let items = undefined as PlanItem[] | undefined // set inside update's closure
295 await update($, plan, held => (items = mirror(held ?? [], tool, input, result)) ?? held ?? [])
296 if (!items) return
297 const shown = items, now = await $.clock.now(), n = await callsNow($)
298 await update($, run, r => (isLive(r) ? intoRun(r, shown, now, n) : r))
299}
300
301// A measure (the context's fill, when it changed) fills the widgets bound to it (hooks/bind.ts).
302const bindMeasure = async ($: EngineInterface, e: meter.Measure, now: number) => {
303 const text = measureText(e)
304 if (text === undefined || !(await read($, run))?.widgets?.some(w => w.bind?.on === 'measure')) return
305 const n = await callsNow($)
306 await update($, run, r => (isLive(r) ? seeBound(r, { tool: MEASURE, input: {}, failed: false, text }, now, n) : r))
307}
308
309const watched = async ($: EngineInterface) => (await $.session.surfaces()).some(s => DRAWS.includes(s))
310
311// Auto-run: a long turn starts its own run. Its memory is in the session's state: the person's last
312// message (its first line, as a title), their turn while auto-run watches it, the last sign of a turn, and the main
313// loop's background jobs still running.
314const prompt = atom({ plugin: 'hud', key: 'prompt' } as const, undefined)
315const turn = atom({ plugin: 'hud', key: 'turn' } as const, null)
316const lastTurnAt = atom({ plugin: 'hud', key: 'lastTurnAt' } as const, undefined)
317const jobs = atom({ plugin: 'hud', key: 'jobs' } as const, {})
318
319// The Agents source (hooks/sources/agents.ts): an event it takes becomes slots, and a line in the log while a run is live.
320// One that changes nothing (an end for an agent that is not running) writes nothing.
321const watchAgents = async ($: EngineInterface, ev: agents.AgentEvent) => {
322 const held = await read($, agentsSeen)
323 if (agents.observe(held, ev).state === held) return
324 let o: Pushes = { push: [], clear: [] }, line = undefined as ReturnType<typeof agents.said>, changed = false as boolean // set inside update's closure
325 await update($, agentsSeen, s => { const r = agents.observe(s, ev); o = r; line = agents.said(s, ev); changed = r.state !== s; return r.state })
326 if (!changed) return
327 await feed($, agents.declaration, o)
328 if (line) await logObserved($, [agentSaid(line)])
329}
330
331// Jobs of the main loop that have ended, by task id (a shell's or a subagent's).
332const stopJobs = async ($: EngineInterface, ids: string[]) => {
333 const held = (await read($, jobs)) ?? {}
334 if (!ids.some(id => id in held)) return
335 await update($, jobs, j => Object.fromEntries(Object.entries(j ?? {}).filter(([id]) => !ids.includes(id))))
336}
337
338// A background subagent has ended: a job no more, and done or failed in the Agents source.
339const endAgent = async ($: EngineInterface, id: string, failed: boolean) => {
340 await stopJobs($, [id])
341 await watchAgents($, { k: 'end', id, failed })
342}
343
344// The main loop's finished Agent call, or TaskStop. A foreground agent (`local`: the call's id, which it goes by until it
345// has one of its own) ends with its result: done on a completed record, failed on an error or a refusal. One that came back
346// launched is a background job until its notification (or its own turn's end) and, if it began in the foreground, keeps
347// its place under its own id: a person can send a foreground agent to the background. Any other record (a teammate
348// joining, a launch in the cloud) was no subagent finishing there, so its entry is dropped. Stopping a task ends the agent
349// it names.
350const agentCall = async ($: EngineInterface, e: Record<string, unknown>, ran: { result?: unknown }, failed: boolean, asked: { name: string } | undefined, local: string | undefined) => {
351 if (e.tool === 'TaskStop') {
352 const id = e.task_id ?? e.shell_id
353 if (!failed && typeof id === 'string') await endAgent($, id, true)
354 return
355 }
356 if (!asked) return
357 const bg = agents.launched(ran.result)
358 if (bg) {
359 await update($, jobs, j => ({ ...j, [bg.id]: 'agent' as const }))
360 await watchAgents($, local ? { k: 'rekey', id: local, as: bg.id } : { k: 'start', id: bg.id, name: bg.name ?? asked.name })
361 } else if (local) await watchAgents($, failed || agents.completed(ran.result) ? { k: 'end', id: local, failed } : { k: 'drop', id: local })
362}
363
364// A sign of life from an agent the session's start left lost (a call of its own): it was at work after all, and a job
365// again. Only a background agent goes by its own id, so only one comes back this way; a foreground one ends with its call.
366const revive = async ($: EngineInterface, id: string) => {
367 if (!(await read($, agentsSeen)).list.some(a => a.id === id && a.lost)) return
368 await update($, jobs, j => ({ ...j, [id]: 'agent' as const }))
369 await watchAgents($, { k: 'live', id })
370}
371
372// A foreground agent can't outlive the main loop's turn: one still at work at its end never had its result (an interrupted
373// call, a fault). Failed, as it never finished. A background agent is a job, and ends by its notification or its own turn.
374const sweepAgents = async ($: EngineInterface) => {
375 const away = (await read($, jobs)) ?? {}
376 for (const a of (await read($, agentsSeen)).list) if (a.state === 'now' && !(a.id in away)) await watchAgents($, { k: 'end', id: a.id, failed: true })
377}
378
379// A background task's notification arrives as a prompt (proved on desktop), but it is no message of the person's.
380const NOTICE = /<task-notification>[\s\S]*?(?:<\/task-notification>|$)/g
381const isNotice = (e: { text: string; origin?: { kind: string } }) =>
382 e.origin?.kind === 'task-notification' || e.text.trimStart().startsWith('<task-notification>')
383// It ends the jobs it names, the subagents among them (completed is done; failed and killed are failed), and nothing
384// else: no note, no new turn, no resume.
385const endJobs = async ($: EngineInterface, text: string) => {
386 const told = (text.match(NOTICE) ?? []).flatMap(b => notification(b) ?? [])
387 if (!told.length) return
388 await stopJobs($, told.map(n => n.id))
389 for (const n of told) await watchAgents($, { k: 'end', id: n.id, failed: n.status !== 'completed' })
390}
391
392// The person's message: their turn begins, auto-run watches it from here, and a run waiting for them or paused picks up.
393// Only the first line is kept, as the title a run started in this turn takes.
394const prompted = async ($: EngineInterface, text: string) => {
395 const now = await $.clock.now()
396 await update($, prompt, () => ({ text: titleOf(text), at: now }))
397 await update($, turn, () => ({ startedAt: now, calls: 0 }))
398 await update($, lastTurnAt, () => now) // before the run: pause() reads it to tell a message landed meanwhile
399 await update($, run, r => (isLive(r) && (r.status === WAITING || r.status === PAUSED) ? { ...r, status: 'running' } : r))
400}
401
402// Auto-run stands down for the rest of the turn: off, not null, so a notification or a hand-back delivered into the turn
403// doesn't begin another. The next turn, whatever begins it, starts over.
404const standDown = ($: EngineInterface, now: number) => update($, turn, () => ({ startedAt: now, calls: 0, off: true as const }))
405
406// Auto-run's watch on a finished call. Any call is a sign of a turn, which the pause reads. The main loop's background
407// shells are jobs until a notification or a TaskStop ends them. And a main-loop turn, whatever began it, that runs long
408// with no live run starts one, provisional, titled from the person's last message, while someone watches; shown, unless
409// the person pressed ✕ in this turn. Answers whether it started one.
410const autoRun = async ($: EngineInterface, e: Record<string, unknown>, ran: { result?: unknown; deny?: string }, now: number) => {
411 await update($, lastTurnAt, () => now)
412 if (e.agentId !== undefined) return false // a subagent's shells and calls are its own
413 const begun = (ran.result as { backgroundTaskId?: unknown } | null | undefined)?.backgroundTaskId
414 const stopped = e.tool === 'TaskStop' && ran.deny === undefined ? e.task_id ?? e.shell_id : undefined
415 if (typeof begun === 'string' || typeof stopped === 'string') {
416 await update($, jobs, j => {
417 const at = { ...j }
418 if (typeof stopped === 'string') delete at[stopped]
419 if (typeof begun === 'string') at[begun] = 'shell'
420 return at
421 })
422 }
423 // counted inside update's closure, which retries on a clash: calls that finish together each count. A turn it stood
424 // down in (off) or none in hand counts nothing.
425 let t = null as { startedAt: number; calls: number; hiddenAt?: number } | null // set inside update's closure
426 await update($, turn, w => ((t = w && !w.off ? { ...w, calls: w.calls + 1 } : null), t ?? w))
427 if (!t) return false
428 // simplification: asks who's watching only once the rest holds: a turn under 8 calls and 60 seconds reads none, and a long one
429 // reads them once, when its run starts; while nobody watches (a -p run, the SDK) it asks again at each call past that
430 if (!shouldStart(isLive(await read($, run)), true, t, now) || !(await watched($))) return false
431 // titled from the message; a plan already held reaches the run the moment it exists
432 const title = titleOf((await read($, prompt))?.text), held = (await read($, plan)) ?? [], n = await callsNow($)
433 await update($, run, r => (isLive(r) ? r : intoRun({ ...fresh(title, now), provisional: true as const }, held, now, n)))
434 if (t.hiddenAt === undefined) await update($, stripHidden, () => false)
435 await standDown($, now) // one run a turn
436 return true
437}
438
439// The person's ✕: the strip hides until the next run, and a run auto-run starts in this same turn stays hidden too.
440const hideStrip = async ($: EngineInterface) => {
441 await update($, stripHidden, () => true)
442 if (!(await read($, turn))) return
443 const now = await $.clock.now()
444 await update($, turn, t => t && { ...t, hiddenAt: now })
445}
446
447// The 1-second clock: a live run with no turn for 30 minutes, and no job at work, reads paused until the next message.
448// A message can land between the reads and the write: the decision is taken again on the run as it stands when written
449// (update retries on a clash), and if a sign of a turn came after the read, the status goes back to what it was (a
450// message's own resume then holds: prompted() writes lastTurnAt before it touches the run).
451// simplification: most seconds end at the first look (the run the clock just read, and the time), so a tick costs two reads
452const pause = async ($: EngineInterface, seen: HudRun) => {
453 const now = await $.clock.now(), last = await read($, lastTurnAt)
454 if (!pauses(seen, last, 0, now)) return
455 const n = jobsRunning({ jobs: await read($, jobs) })
456 if (n > 0) return
457 let from: string | undefined // set inside update's closure
458 await update($, run, r => ((from = r && pauses(r, last, n, now) ? r.status : undefined), from !== undefined && r ? { ...r, status: PAUSED } : r))
459 if (from !== undefined && (await read($, lastTurnAt)) !== last) {
460 const was = from
461 await update($, run, r => (r?.status === PAUSED ? { ...r, status: was } : r))
462 }
463}
464
465const expand = async ($: EngineInterface) => {
466 await $.ui.open({ id: PANE, title: 'HUD' })
467 await update($, paneOpen, () => true)
468}
469
470// A plugin's own $.ui.close never reaches its own ui.close hook, so the flag is set here too.
471const collapse = async ($: EngineInterface) => {
472 await $.ui.close({ id: PANE })
473 await update($, paneOpen, () => false)
474}
475
476// The tool is offered once a surface that draws the strip is attached: nobody sees a HUD under -p, the SDK,
477// or a scheduled run nobody opened. The desktop app attaches after session.start, so attach offers it too.
478// simplification: this load's registration; a reload is a fresh module, which registers again
479let offered = false
480const offer = async ($: EngineInterface) => {
481 if (offered) return
482 await $.tool.register({ name: 'hud', description: DESCRIPTION, inputSchema: SCHEMA, isDeferred: false })
483 offered = true
484}
485
486export const register: Register = on => {
487 // $.hud: other plugins (those that list "hud" under "dependencies") send slots through it. Each call raises
488 // hud.<method>, which any plugin may hook; the HUD's own hooks sit innermost, beneath its dependents (proved in the app).
489 on('engine.create', async ($, e, next) => ({ ...(await next(e)), hud: HUD }))
490 // No .catch on these three, on purpose: they fail closed, so a fault in the gate refuses a source's slot (the noun's
491 // own answer, UNHEARD) rather than letting it through unchecked.
492 on('hud.declare', async ($, e, next) => ({ value: await declare($, next.origin.plugin, e) }))
493 on('hud.push', async ($, e, next) => ({ value: await push($, next.origin.plugin, e) }))
494 on('hud.clear', async ($, e, next) => ({ value: await clear($, next.origin.plugin, e) }))
495
496 on('session.attach', async ($, e, next) => {
497 const joined = await next(e)
498 if (DRAWS.includes(e.surface)) await offer($)
499 return joined
500 })
501
502 on('session.start', async ($, e, next) => {
503 if (await watched($)) await offer($)
504 await $.command.register({ name: 'hud', description: 'Show the task HUD strip; mode, pin or hide a slot, sources, mute or unmute a source' })
505 // the person's mutes, pins and hides, from past sessions; a store that can't be read leaves none
506 const kept = await $.store.get('muted').catch(() => undefined)
507 if (Array.isArray(kept)) await update($, muted, () => kept.filter((k): k is string => typeof k === 'string'))
508 const keptPrefs = await $.store.get('prefs').catch(() => undefined)
509 if (typeof keptPrefs === 'object' && keptPrefs !== null) await update($, prefs, () => prefsOf(keptPrefs))
510 // background shells don't outlive the process that ran them: a resumed session's are gone, never "1 job running".
511 // Subagents at work read lost: failed, as on a resume, until a sign of their life (their call, notification or own
512 // turn's end) says the process stayed, as on a reload, an enable or a respawn, which session.start can't tell apart.
513 await update($, jobs, () => ({}))
514 for (const a of (await read($, agentsSeen)).list) if (a.state === 'now') await watchAgents($, { k: 'lose', id: a.id })
515 // simplification: 1s redraw for elapsed/countdown, and the pause; only while a run is live
516 $.clock.every(1000, async () => {
517 const r = await read($, run)
518 if (!isLive(r)) return
519 await pause($, r)
520 await $.ui.invalidate('ui.render')
521 })
522 return next(e)
523 })
524
525 // simplification: the strip only; the full pane opens from the strip's Expand, never by itself. It also says in full what
526 // the header cut ("…"): the band can't reveal it on hover.
527 on('command.run', { command: 'hud' }, async ($, e) => {
528 const [sub = '', ...rest] = (e.args ?? '').trim().split(/\s+/)
529 if (['sources', 'mute', 'unmute'].includes(sub)) return { text: await trust($, sub, rest.join(' ')) }
530 if (['mode', 'pin', 'unpin', 'hide', 'unhide'].includes(sub)) return { text: await control($, sub, rest.join(' ')) }
531 const raw = await read($, run)
532 const shown = await showing($)
533 if (raw === null) return { text: ['No HUD yet. Claude starts one for a long task, or ask for one.', ...shown].join('\n') }
534 await update($, stripHidden, () => false) // brings back a strip the person hid with ✕
535 const r = (await drawable($)).run(raw), now = await $.clock.now(), n = jobsRunning({ jobs: await read($, jobs) })
536 // one line per slot in the strip: the boundary decided which and in what order; the why (and its tone) is as it is now
537 const arranged = await read($, arrangement), placed = await board($, r)
538 const inStrip = (arranged?.strip ?? []).flatMap(k => {
539 const p = placed.find(x => x.key === k)
540 return p ? [`${plain(p.slot.title)} — ${p.why}`] : []
541 })
542 return {
543 text: [plain(r.title), r.goal && `Goal: ${plain(r.goal)}`, `${sentence(r.status)} · ${factsOf(r, now, [], Infinity, n)}`, ...inStrip, ...shown,
544 'The strip is back above the prompt. Expand opens the dashboard.'].filter(Boolean).join('\n'),
545 }
546 })
547
548 // The person's close (the pane's mark, ctrl+x x) flips the strip's button back to Expand.
549 on('ui.close', async ($, e, next) => {
550 const closed = await next(e)
551 if (e.id === PANE) await update($, paneOpen, () => false)
552 return closed
553 }).catch(($, e, next) => next(e)) // a fault in the HUD never stands in the way of the close (replay-safe)
554
555 // The compact strip above the prompt; Expand opens the full dashboard pane.
556 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
557 const raw = await read($, run)
558 if (raw === null || e.props.hasSurvey || (await read($, stripHidden))) return next(e)
559 const view = await drawable($), r = view.run(raw) // the one view: no reading where none, nothing hidden
560 // Every mod's pane shares one dock: another's can take it with no ui.close for ours, so the flag alone
561 // goes stale. Shown is what the dock says.
562 const isOpen = (await read($, paneOpen)) && (await $.ui.panes()).some(p => p.id === PANE && p.isShown)
563 const now = await $.clock.now(), n = jobsRunning({ jobs: await read($, jobs) })
564 const last = lastLine(r.log)
565 const factsBut = (drawn: string[], stepRoom = Infinity) => factsOf(r, now, drawn, stepRoom, n)
566 const title = plain(r.title), goal = r.goal && plain(r.goal)
567 // a run that started on its own draws its title dim until Claude names it
568 const dim = r.provisional ? { dimColor: true } : {}
569 const toggle = { key: 'toggle', label: isOpen ? 'Collapse' : 'Expand ⤢', onPress: () => void (isOpen ? collapse($) : expand($)) }
570 const hide = { key: 'hide', label: '✕', plain: true as const, dimColor: true, onPress: () => void hideStrip($) }
571
572 if (e.surface === 'terminal') {
573 const { Box, Text, Button } = $.ui.resolve(e)
574 const cols = e.props.bodyColumns
575 const f = r.progress ? Math.min(1, Math.max(0, r.progress.n / r.progress.total)) : undefined // (clamped: a run from before)
576 // With the dashboard open beside the band it keeps what the desktop's keeps, a line of status, title and its buttons:
577 // the pane has the rest. Closed: the urgency place first, live as it stands; then the very items the desktop strip
578 // chose (stripPicked(), the arrangement's slots and the run's own, or the default pick with no arrangement), one line
579 // each, a slot as the board has it now with its why, so a hidden or muted one draws nowhere and a figure, why and tone
580 // are never the stored ones. Nothing is left to the terminal to cut: every text is fitted to the columns it has
581 // (fitLine(), headerOf(), cutText()), so no number is cut, and a figure sits in a box that never shrinks.
582 const held = await view.held(), urgency = isOpen ? undefined : urgentFor(held, (await read($, urgent)) ?? undefined)
583 const alarm = urgency && alarmLine(urgency)
584 const picked = isOpen ? [] : stripPicked(r, held, urgency, (await read($, arrangement)) ?? undefined)
585 const lines = isOpen ? [] : bandLines(picked, await board($, r), r, cols)
586 // a status other than the usual says so in words: colour alone is no reading ("Waiting for you")
587 // (and in a band too narrow to hold the word beside its buttons, by the colour of the dot, as a running one is)
588 const said = r.status === 'running' ? '' : `${sentence(r.status)} `
589 const word = cols - len(`● ${said}`) - (len(toggle.label) + len(hide.label) + 8) >= 10 ? said : ''
590 const head = headerOf({ r, now, jobs: n, cols, dot: len(`● ${word}`), buttons: len(toggle.label) + len(hide.label) + 8, hasBar: f !== undefined, picked: picked.map(p => p.key), drawn: lines.map(l => l.key), open: isOpen })
591 return (
592 <Box flexDirection="column">
593 {alarm && <Text color={alarm.color} bold wrap="truncate-end">{cutText(alarm.text, cols)}</Text>}
594 <Box flexDirection="row">
595 <Text color={COLOR[r.status] ?? 'text'} bold>● {word}</Text>
596 <Box flexGrow={1} flexShrink={1}><Text bold wrap="truncate-end">{r.provisional ? <Text dimColor>{head.title}</Text> : head.title}{head.goal && <Text bold={false}> → {head.goal}</Text>}</Text></Box>
597 {head.facts && <Box flexShrink={0}><Text dimColor> {head.facts}</Text></Box>}
598 {head.bar > 0 && f !== undefined && <Text> <Text color="claude">{'█'.repeat(Math.round(f * head.bar))}</Text><Text dimColor>{'░'.repeat(head.bar - Math.round(f * head.bar))} </Text></Text>}
599 <Button {...toggle} variant="primary" />
600 <Button {...hide} />
601 </Box>
602 {[...head.own, ...lines].map(l => (
603 <Box flexDirection="row">
604 <Box flexShrink={1}><Text wrap="truncate-end"> <Text dimColor>{l.title}: </Text></Text></Box>
605 <Box flexShrink={0}><Text dimColor={l.old}>{l.figure}</Text></Box>
606 {l.why && <Box flexShrink={3}><Text dimColor italic wrap="truncate-end"> {l.why}</Text></Box>}
607 </Box>
608 ))}
609 {!isOpen && last && <Text dimColor wrap="truncate-end"> {fitText(last.text, cols - 2)}</Text>}
610 </Box>
611 )
612 }
613
614 const { Box, Text, Button, Svg } = $.ui.resolve(e)
615 const held = await view.held(), placed = await board($, r)
616 // simplification: ~8px per column, as the pane; the Svg scales down to its slot if that guess is wide
617 const width = Math.min(1400, Math.max(320, e.props.bodyColumns * 8 - 8))
618 // With the dashboard open beside the conversation the band keeps its header row alone: the pane
619 // draws all of it, and the conversation gets the height back.
620 // a run with nothing to draw yet (just started: no figures, no plan) shows its header alone
621 // The slots it draws are the ones decided at the last boundary, their values as they are now (a run from before
622 // there was one takes its default pick).
623 const urgency = urgentFor(held, (await read($, urgent)) ?? undefined)
624 const drawnStrip = isOpen ? undefined : strip(r, width, held, urgency, (await read($, arrangement)) ?? undefined)
625 const graphic = drawnStrip?.shown.length ? drawnStrip : undefined
626 // the alt leads with the urgency, so the slot's own entry doesn't say it again
627 const lead = urgency && `${urgency.slot.from}/${urgency.slot.id}`
628 const told = lead ? placed.map(p => (p.key === lead ? { ...p, why: '' } : p)) : placed
629 const alt = (urgency ? `Urgent: ${plain(urgency.says)}. ` : '') + describe(r, held, told)
630 const drawn = graphic?.shown ?? []
631 // simplification: no-break spaces inside the status too ("Waiting for you" never breaks)
632 const status = `●${NBSP}${sentence(r.status).replace(/ /g, NBSP)}${NBSP}${NBSP}`
633 // The title and goal are laid out here, in measure()'s units (its SVG widths run wide: ×0.85 still leaves the
634 // app's 13px text slack), so the desktop never wraps them on its own (the 1.0 band split "● done" that way).
635 // A column is 7px, under any code font's advance; the two buttons take their labels and 64px.
636 // The title keeps a floor and the step in hand takes what is left, so a long step never pushes the title
637 // or the buttons out of the row (seen live with the dashboard open).
638 const row = (e.props.bodyColumns * 7 - 64) / 0.85 - measure(status, 13, BOLD) - measure(toggle.label, 13)
639 const titleFloor = Math.min(measure(title, 13, BOLD), Math.max(120, row * 0.35))
640 const pad = measure(`${NBSP}${NBSP}${NBSP}${NBSP}`, 13)
641 // With the dashboard open beside it, the band goes minimal (the person's ask): one line of status, title,
642 // Collapse and ✕. The pane right there shows the clock, goal, progress, step and errors.
643 const facts = isOpen ? '' : factsBut(drawn, row - titleFloor - pad - measure(factsBut(drawn, 0), 13) - measure(' · ▸ ', 13))
644 const room = row - (facts ? pad + measure(facts, 13) : 0)
645 // Long text wraps rather than vanishing (the person's pick over shrinking or a marquee; native text has no
646 // size to shrink): the title takes two lines at most, cut after, and the goal a dim line of its own under it.
647 // /hud prints both whole. (No hover cards: the band never reveals them, proved in the app.)
648 const titleLines = isOpen ? [fit(title, room, 13, BOLD)] : wrapPx(title, room, 13, 2, BOLD)
649 const shownGoal = goal && !isOpen ? fit(goal, room - measure('→ ', 13), 13) : ''
650 // the band's whole width, less the clock in front
651 const stamp = last ? `${clock(last.at - r.startedAt)}${NBSP}${NBSP}` : ''
652 const lastText = last ? stamp + fit(last.text, (e.props.bodyColumns * 7 - 16) / 0.85 - measure(stamp, 13), 13) : ''
653 return (
654 <Box flexDirection="column">
655 <Box flexDirection="row" alignItems="flex-start">
656 <Box flexShrink={0}><Text color={COLOR[r.status] ?? 'text'} bold>{status}</Text></Box>
657 <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
658 {titleLines.map(l => <Text bold wrap="truncate-end" {...dim}>{l}</Text>)}
659 {shownGoal ? <Text dimColor wrap="truncate-end">→ {shownGoal}</Text> : null}
660 </Box>
661 {facts ? <Box flexShrink={1} minWidth={0}><Text dimColor wrap="truncate-end">{NBSP}{NBSP}{facts}{NBSP}{NBSP}</Text></Box> : null}
662 <Button {...toggle} />
663 {/* the surface's own close control at the band's trailing edge. simplification: the label is ✕, not words, as a
664 desktop that predates role="dismiss" draws the label itself ("Hide HUD strip" as a text chip) */}
665 <Button {...hide} role="dismiss" />
666 </Box>
667 {/* the alt says in words all the run holds, as a reader of it can't see what the header leaves to the graphic */}
668 {graphic && <Svg source={graphic.source} alt={alt} width={graphic.width} height={graphic.height} />}
669 {/* the last line is cut here too, by fit(): the app's own cut lands mid-word ("hides t…") */}
670 {!isOpen && last && <Text dimColor wrap="truncate-end">{lastText}</Text>}
671 </Box>
672 )
673 })
674
675 // Answers the hud tool; feeds every other call to the HUD's own sources; counts it and its error while a run is
676 // live (a main-loop call also ticks the clocks widgets age by), and once the HUD has gone quiet, has Claude check
677 // it still fits the work (a reminder after the result). Widgets gone stale are named in that check instead.
678 // simplification: one unmatched hook, as a { tool } matcher over ~1000 MCP names is too deep for tsc
679 on('tool.call', async ($, e, next) => {
680 if (String(e.tool) === TOOL) {
681 // The HUD is the main conversation's: nobody watches a subagent's, and its start or done would end the main run.
682 if (e.agentId !== undefined) return { result: 'The HUD shows the main conversation only; this call changed nothing.' }
683 const now = await $.clock.now(), n = await callsNow($)
684 const input = e as unknown as Input
685 // a run started on its own is adopted only in the turn of the message that began it (since that message)
686 const since = (await read($, prompt))?.at, held = (await read($, plan)) ?? []
687 let adopted = false, counted = false // set inside update's closure
688 const lines: string[] = [] // what apply() has for the answer: a recipe's line, a binding it could not take; set inside update's closure
689 await update($, run, prev => {
690 adopted = adopts(prev, input, since)
691 lines.length = 0
692 const r = apply(prev, input, now, since, lines, n)
693 // apply() counts a phase or a rebuild as a boundary itself, and a start is a run of its own
694 counted = input.start !== undefined || (r.boundaries ?? 0) !== (prev?.boundaries ?? 0)
695 return adopted ? intoRun(r, held, now, n) : r // the held plan, the moment the run is Claude's
696 })
697 // The run took the call. What follows rides its answer, so a fault in it is swallowed, as weighUrgency()'s is: the
698 // answer never says "unchanged" of a call that applied (the .catch would), and the strip waits for the next boundary.
699 let notes: string[] = []
700 try {
701 // simplification: a new run shows the strip, not the pane; only the strip's Expand opens the full view. A start that
702 // adopts the run begun on its own is no new run: the strip stays as the person left it.
703 if (input.start !== undefined && !adopted) await update($, stripHidden, () => false)
704 // Claude has the HUD in hand: auto-run stands down until the next turn
705 await standDown($, now)
706 await update($, lastTurnAt, () => now)
707 // a call that says what matters (mode, focus, strip) is a boundary too, counted once beside a phase or a rebuild
708 const sent = e as unknown as Record<string, unknown>, says = ['mode', 'focus', 'strip'].some(k => sent[k] !== undefined)
709 if (says && !counted) await update($, run, r => (isLive(r) ? boundary(r, now) : r))
710 await weighUrgency($) // a boundary ends a hold
711 notes = await intend($, input)
712 // the strip is decided again at any of them, and at a start
713 if (says || counted) await arrange($)
714 } catch { /* the run stands as the call left it */ }
715 return { result: answer(input, [...notes, ...lines]) }
716 }
717 // counted as it starts, so what changes during it (a source's push, the HUD's own sources after) is stamped with it
718 if (e.agentId === undefined) await update($, calls, k => (k ?? 0) + 1)
719 else await revive($, e.agentId) // a subagent at work: one the session's start left lost is back
720 // The main loop's Agent call: a foreground agent is at work from here to its result (hooks/sources/agents.ts), known
721 // by its call's id until it has one of its own
722 const asked = e.agentId === undefined && String(e.tool) === 'Agent' ? agents.call(e as unknown as Record<string, unknown>) : undefined
723 const local = asked?.foreground && typeof e.tool_use_id === 'string' ? e.tool_use_id : undefined
724 if (asked && local) await watchAgents($, { k: 'start', id: local, name: asked.name })
725 const ran = await next(e)
726 const failed = ran.deny !== undefined || ran.isError === true
727 // First, before anything that could fault: a foreground agent at work since the call began ends with its result
728 if (e.agentId === undefined) await agentCall($, e as unknown as Record<string, unknown>, ran, failed, asked, local)
729 // The tool signals and test results read every finished call, whoever made it and whether or not a run is
730 // live: slots outlive the run. (One unmatched tool.call hook per plugin, so they ride this one.)
731 const finished = { tool: String(e.tool), input: e as unknown as Record<string, unknown>, failed, denied: ran.deny !== undefined, text: ran.text }
732 const tests = await observeCall($, finished)
733 await weighUrgency($)
734 // The auto-log: what the main loop's call did becomes a line (a subagent's work is the subagent's, not the run's)
735 if (e.agentId === undefined) await logObserved($, said(finished, tests.before, tests.after))
736 if (e.agentId === undefined && !failed) await mirrorPlan($, finished.tool, finished.input, ran.result)
737 const now = await $.clock.now()
738 // a run that starts on its own counts from the next call, which tells Claude of it
739 if (await autoRun($, finished.input, ran, now)) {
740 await arrange($) // a run's start is a boundary, whoever starts it
741 return ran
742 }
743 if (!isLive(await read($, run))) return ran
744 // simplification: only the main loop is reminded, and only while someone watches; the count goes on, so a person
745 // who comes back finds it checked on the next call. A subagent's calls count on the board but not toward
746 // the main loop's check: three subagents at work would otherwise have it checked every couple of calls.
747 const main = e.agentId === undefined
748 const remind = main && ran.deny === undefined && (await watched($))
749 // The main loop's call fills the widgets bound to it (hooks/bind.ts); one denied never ran, and a task-list call that
750 // failed changed no list. A task-list call is read as the plan's done of total, as the mirror holds it now.
751 // simplification: a job sent to the background (a shell's backgroundTaskId, an Agent's launch) has no result yet, so it is no
752 // reading and no miss; its real output (the notification, TaskOutput) isn't read. A later task can bind to it.
753 const away = typeof (ran.result as { backgroundTaskId?: unknown } | null | undefined)?.backgroundTaskId === 'string' || (asked && agents.launched(ran.result))
754 const seen = main && ran.deny === undefined && !away && !(failed && PLAN_TOOLS.includes(finished.tool))
755 ? { ...finished, text: PLAN_TOOLS.includes(finished.tool) ? planText((await read($, plan)) ?? []) : finished.text }
756 : undefined
757 let note: string | undefined // set inside update's closure
758 const stampN = await callsNow($) // a bound widget's reading is stamped with it
759 await update($, run, r => {
760 note = undefined
761 if (!r) return r
762 // a main-loop call is Claude at work: a run waiting for the person or paused runs again, whatever began the turn
763 const tallied = {
764 ...r, ...(main && (r.status === WAITING || r.status === PAUSED) ? { status: 'running' } : {}), calls: r.calls + 1, errors: r.errors + (failed && ran.deny === undefined ? 1 : 0), // a denial is a refusal, not the command failing
765 sinceUpdate: (r.sinceUpdate ?? 0) + (main ? 1 : 0), ticks: (r.ticks ?? 0) + (main ? 1 : 0), tickAt: main ? now : r.tickAt,
766 }
767 const counted = seen ? seeBound(tallied, seen, now, stampN) : tallied
768 if (!remind) return counted
769 // A run that started on its own: Claude is told once (it names it with title and goal), and never checked on.
770 // The observer keeps it right; a check on a run Claude didn't start is noise. Adopted, it is checked as any.
771 if (counted.provisional) {
772 if (counted.told) return counted
773 note = autoNote(counted.title)
774 return { ...counted, told: true as const }
775 }
776 // One HUD check per call: the pointed one when widgets of Claude's went stale, else the general one when it's due
777 // and no binding has seen its calls (isBound(): a bound widget keeps itself, and is named when it goes stale). While a check waits on an
778 // answer, the next (pointed too) waits for its window, which doubles for each gone unanswered.
779 const ids = pointedIds(counted), due = stale(counted)
780 const naming = ids.length > 0 && (!counted.nudged || due)
781 note = naming ? pointed(counted) : due && !isBound(counted) ? nudge(counted) : undefined
782 if (note === undefined) return counted
783 const n = counted.nudged ? (counted.nudged.n ?? 0) + 1 : 1
784 return { ...counted, nudged: { at: now, calls: counted.sinceUpdate ?? 0, n }, ...(naming ? { named: [...(counted.named ?? []), ...ids] } : {}) }
785 })
786 if (note === undefined || ran.deny !== undefined) return ran
787 return { ...ran, context: [...(ran.context ?? []), note] }
788 }).catch(($, e, next) =>
789 // a fault in the HUD never costs the call: another tool runs as if the HUD weren't there (replay-safe),
790 // and a HUD update that failed says so instead of reading as done
791 String(e.tool) === TOOL ? { deny: 'The HUD could not apply that update; it is unchanged.' } : next(e))
792
793 // The main loop's turn ending is a phase boundary: what has been stale across two of them leaves the board, and the
794 // strip is decided again. A held urgency goes. Auto-run's watch on the turn ends, and a live run Claude didn't finish
795 // waits for the person, unless background jobs still work.
796 // Never a finish: only Claude sends done or fail. A background subagent's own turn ending (it carries its `agentId`:
797 // proved in the app) is its end: done if it answered, failed if it was interrupted, refused or errored. The main loop's
798 // turn ending first fails any foreground agent still at work (sweepAgents()): its call never came back. (The main
799 // loop's own turn.complete is proved in the app too: reason answer, no agentId; see DEVELOPING.)
800 // simplification: one turn.complete hook; later observers ride it
801 on('turn.complete', async ($, e, next) => {
802 const done = await next(e)
803 if (e.agentId !== undefined) await endAgent($, e.agentId, e.reason !== 'answer')
804 else {
805 await sweepAgents($)
806 const now = await $.clock.now()
807 await update($, turn, () => null)
808 await update($, lastTurnAt, () => now)
809 const live = isLive(await read($, run))
810 if (live) {
811 const waits = jobsRunning({ jobs: await read($, jobs) }) === 0
812 await update($, run, r => (isLive(r) ? { ...boundary(r, now), ...(waits ? { status: WAITING } : {}) } : r))
813 }
814 // no run counts boundaries, so then the turn's end is one: it ends a hold
815 await weighUrgency($, live ? undefined : u => u.heldTo !== undefined)
816 await arrange($)
817 }
818 return done
819 }).catch(($, e, next) => next(e)) // the turn ends as it would without the HUD
820
821 // Context, cost and the rate-limit windows: the figures the app measures after each turn, pushed, never polled.
822 on('session.measure', async ($, e, next) => {
823 const measured = await next(e)
824 const now = await $.clock.now()
825 await feed($, meter.declaration, meter.observe(e, now))
826 await bindMeasure($, e, now)
827 await weighUrgency($)
828 return measured
829 }).catch(($, e, next) => next(e))
830
831 // A compaction of the main conversation, once it stands, is a line in the log. A precompute only prepares one, a veto
832 // changes nothing, and a subagent's is its own transcript's.
833 on('session.compact', async ($, e, next) => {
834 const done = await next(e)
835 if (done.skip === undefined && e.trigger !== 'precompute' && e.agentId === undefined) await logObserved($, [{ kind: 'compact' }])
836 return done
837 }).catch(($, e, next) => next(e))
838
839 // A message that arrives mid-run may move the goal: Claude reads the goal and the ids the strip shows beside it
840 // (steer(): the arrangement's keys, ids only, never a slot's words); beside a run that started on its own and that Claude
841 // has not named, the note that names it (autoNote()). With no run live, a message someone is watching carries the note
842 // to start one for a long task. Any prompt begins a turn auto-run watches; only the person's begins one afresh, titles a
843 // run and resumes one, and only once it went through. A background task's notification also ends the jobs it names.
844 on('prompt.submit', async ($, e, next) => {
845 // Only the person's own typing (origin composer; none, as old hosts send) is their message: it gets a note, titles a
846 // run and resumes a paused one. Any other origin (a task notification, a subagent's hand-back as a peer, the rest)
847 // leaves the prompt and a paused run alone, and a task notification ends the jobs it names; but it begins a main-loop
848 // turn all the same, which auto-run watches from here when it has none in hand (one delivered into a running turn
849 // leaves that turn as it is) once it went through: a refused one begins nothing.
850 if (isNotice(e)) await endJobs($, e.text)
851 if (isNotice(e) || (e.origin?.kind ?? 'composer') !== 'composer') {
852 const entered = await next(e)
853 try {
854 if (entered.drop === undefined) {
855 const now = await $.clock.now()
856 await update($, turn, t => t ?? { startedAt: now, calls: 0 })
857 }
858 } catch { /* past next(e): a fault here must not deliver it again */ }
859 return entered
860 }
861 const r = await read($, run)
862 // a run that started on its own and that Claude has not named carries the note that names it, never the steer: it may
863 // hold an earlier task's title, and Claude can name it for this one (a title adopts it whatever its age)
864 const note = isLive(r) ? (r.provisional ? autoNote(r.title) : steer(r, (await read($, arrangement))?.strip)) : offered && (await watched($)) ? KICKOFF : undefined
865 const entered = await next(note ? { ...e, context: [...(e.context ?? []), note] } : e)
866 // only a message that went through is the person's turn: one refused on its way in keeps no title, begins no turn
867 // and resumes nothing. (Past next(e), a fault here must not send the message again: the .catch would.)
868 try {
869 if (entered.drop === undefined) await prompted($, e.text)
870 } catch { /* the message went on: a fault in the HUD costs it nothing */ }
871 return entered
872 }).catch(($, e, next) => next(e)) // the message goes on without the note
873
874 // What Claude needs to know about the HUD (hooks/skill.ts), once per session and then cached with the system prompt:
875 // one constant section after the engine's own, only where the hud tool is offered, which is where someone watches.
876 // Never built from the run or a slot.
877 on('prompt.compose', async (_$, e, next) => {
878 const composed = await next(e)
879 return e.tools.includes(TOOL) ? { sections: [...composed.sections, { id: 'hud:skill', text: SKILL, scope: 'session' as const }] } : composed
880 }).catch((_$, e, next) => next(e)) // the prompt goes on as the engine made it
881
882 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
883 // the one view: a bound widget gone stale says "no reading"; what the person hid isn't drawn
884 const view = await drawable($), raw = await read($, run), r = raw && view.run(raw)
885 const now = await $.clock.now()
886 const cols = e.props.bodyColumns ?? 60
887
888 // Sources: who sends what the HUD shows, what each says it can access (as it wrote it), its slots, and whether
889 // it's muted. Muting is by command: a Mute button in the pane never fired in the app (DEVELOPING, Constraints).
890 const { Box, Text } = $.ui.resolve(e)
891 const off = await read($, muted)
892 const sourceList = (
893 <Box flexDirection="column" marginTop={1}>
894 <Text bold>Sources</Text>
895 {sourcesOf(await read($, sources)).map(src => (
896 <Box flexDirection="column" marginTop={1}>
897 <Text><Text bold>{plain(src.name)}</Text><Text dimColor>{` · ${whose(src)}`}</Text>{off.includes(src.key) && <Text color="warning"> · muted</Text>}</Text>
898 <Text dimColor>{plain(src.access)}</Text>
899 <Text dimColor>{src.slots.map(plain).join(', ')}</Text>
900 </Box>
901 ))}
902 <Box marginTop={1}><Text dimColor>{'Hide one with /hud mute <name> and bring it back with /hud unmute <name>. /hud sources lists the names.'}</Text></Box>
903 </Box>
904 )
905 // What decides the order: the mode, and what the person pinned or hid. Set by command, as a pane button never fired.
906 const controls = (
907 <Box flexDirection="column" marginTop={1}>
908 <Text bold>Showing</Text>
909 {(await showing($)).map(l => <Text>{plain(l)}</Text>)}
910 <Text dimColor>{'Switch with /hud mode <name>. /hud pin <slot> keeps one first, /hud hide <slot> leaves it out.'}</Text>
911 </Box>
912 )
913 const none = <Box flexDirection="column"><Text dimColor>No run yet. Ask Claude for a HUD on a long task.</Text>{controls}{sourceList}</Box>
914 // a run that started on its own: its title dim until Claude names it; background jobs at work, while it's live
915 const dim = r?.provisional ? { dimColor: true } : {}
916 const n = r && isLive(r) ? jobsRunning({ jobs: await read($, jobs) }) : 0
917
918 if (e.surface !== 'terminal') {
919 const { Svg } = $.ui.resolve(e)
920 if (r === null) return none
921 // simplification: ~8px per column; the Svg scales down to its slot if that guess is wide. The floor is low
922 // on purpose: a docked pane is often under 340px, and drawing wider shrinks every label below the app's text.
923 const width = Math.min(1400, Math.max(240, cols * 8))
924 // the pane is the full board, ranked as it stands: a slot shows here at once, the strip waits for a boundary
925 const held = await view.held(), placed = await board($, r)
926 const { source, height } = dashboard({ ...r, widgets: r.widgets ?? [] }, width, held, placed)
927 const quiet = quietFor(r, now)
928 return (
929 <Box flexDirection="column">
930 <Text bold {...dim}>{plain(r.title)}</Text>
931 {r.goal && <Text><Text dimColor>Goal </Text>{plain(r.goal)}</Text>}
932 <Text>
933 <Text color={COLOR[r.status] ?? 'text'} bold>● {sentence(r.status)}</Text>
934 <Text dimColor> ⏱ {clock((r.endedAt ?? now) - r.startedAt)}</Text>
935 {n > 0 && <Text dimColor> · {jobsText(n)}</Text>}
936 {isLive(r) && r.nextAt !== undefined && r.nextAt > now && (
937 <Text color="suggestion"> ↻ next in {clock(r.nextAt - now)}</Text>
938 )}
939 {quiet !== undefined && <Text dimColor> · updated {span(quiet)} ago</Text>}
940 </Text>
941 {/* the text in text colour; the mark beside it carries the outcome */}
942 {r.summary !== undefined && <Text><Text color={COLOR[r.status]} bold>{r.status === 'failed' ? '✕' : '✓'} </Text>{plain(r.summary)}</Text>}
943 <Svg source={source} alt={describe(r, held, placed)} width={width} height={height} />
944 {/* simplification: no isInteractive; its sandboxed frame reloads blank on every redraw (each scroll step re-asks the hook) */}
945 {controls}
946 {sourceList}
947 </Box>
948 )
949 }
950
951 if (r === null) return none
952 // Nothing is left to the terminal to cut: a line that holds a number is fitted to `cols` here (blockLine(), cutText(),
953 // fitText()), and the rest wraps as Ink wraps it, whole.
954 // Claude's tiles, then the run's own counts apart, as the dashboard draws them (errors only above 0): a tile of Claude's
955 // named "errors" or "tool calls" is its own figure, never the run's count
956 const tiles = [...Object.entries(r.tiles).map(([k, v]) => ({ k, v, at: r.tileClock?.[k] })), { k: 'tool calls', v: r.calls, at: undefined }, ...(r.errors ? [{ k: 'errors', v: r.errors, at: undefined }] : [])]
957 // the board as it stands, ranked; the urgency place leads the pane as a line of its own, as it leads the band
958 const ranked = await board($, r), urgency = urgentFor(await view.held(), (await read($, urgent)) ?? undefined)
959 const alarm = urgency && alarmLine(urgency)
960 const counts = Object.entries(r.counts).sort((a, b) => b[1] - a[1])
961 const top = Math.max(1, ...counts.map(c => c[1]))
962 const room = Math.max(3, (e.viewport?.rows ?? 30) - 20)
963 const quiet = quietFor(r, now)
964
965 return (
966 <Box flexDirection="column">
967 {alarm && <Text color={alarm.color} bold>{cutText(alarm.text, cols)}</Text>}
968 <Text bold {...dim}>{plain(r.title)}</Text>
969 {r.goal && <Text><Text dimColor>goal </Text>{plain(r.goal)}</Text>}
970 <Text>
971 <Text color={COLOR[r.status] ?? 'text'}>● {r.status}</Text>
972 <Text dimColor> · {clock((r.endedAt ?? now) - r.startedAt)}</Text>
973 {n > 0 && <Text dimColor> · {jobsText(n)}</Text>}
974 {isLive(r) && r.nextAt !== undefined && r.nextAt > now && (
975 <Text color="suggestion"> · next in {clock(r.nextAt - now)}</Text>
976 )}
977 {quiet !== undefined && <Text dimColor> · updated {span(quiet)} ago</Text>}
978 </Text>
979 {r.summary !== undefined && <Text color={COLOR[r.status]}>{plain(r.summary)}</Text>}
980
981 <Box flexDirection="row" flexWrap="wrap" marginTop={1}>
982 {tiles.map(({ k, v, at }) => (
983 <Box flexDirection="column" borderStyle="round" paddingX={1} marginRight={1}>
984 <Text dimColor>{k}</Text>
985 <Text bold dimColor={life(at ?? {}, r) !== 'fresh'}>{fmt(v)}</Text>
986 {/* when it last changed, as the dashboard's block says it */}
987 {agoOf(at, r) && <Text dimColor>{agoOf(at, r)}</Text>}
988 </Box>
989 ))}
990 </Box>
991
992 {r.progress && (() => {
993 // the number first, then the bar in what is left of the line
994 const { n, total, label } = r.progress, head = `${n}/${total}${label ? ` ${plain(label)}` : ''}`, room = cols - len(head) - 2
995 const fill = Math.round((Math.max(0, Math.min(n, total)) / total) * room) // (clamped: a run from before)
996 return (
997 <Text>
998 {head}
999 {room >= 5 && <Text> <Text color="success">{'█'.repeat(fill)}</Text><Text dimColor>{'░'.repeat(room - fill)}</Text></Text>}
1000 </Text>
1001 )
1002 })()}
1003
1004 {r.phases.length > 0 && (
1005 <Text>
1006 {r.phases.map((p, idx) => (
1007 <Text color={COLOR[p.state] ?? 'inactive'}>{idx ? ' → ' : ''}{plain(p.name)}</Text>
1008 ))}
1009 </Text>
1010 )}
1011
1012 {r.cells.length > 0 && (
1013 <Text>
1014 {r.cells.map(c => (
1015 <Text color={COLOR[c.state] ?? 'inactive'}>■ {c.name} </Text>
1016 ))}
1017 </Text>
1018 )}
1019
1020 {r.points.length > 1 && (() => {
1021 const head = `last ${fmt(r.points.at(-1))}`, k = Math.min(r.points.length, cols - len(head) - 2)
1022 return (
1023 <Text>
1024 <Text dimColor>{head}</Text>
1025 {k >= 4 && <Text> <Text color="claude">{spark(r.points.slice(-k))}</Text></Text>}
1026 </Text>
1027 )
1028 })()}
1029
1030 {counts.length > 0 && (
1031 <Box flexDirection="column">
1032 {counts.slice(0, 6).map(([k, n]) => (
1033 <Text>
1034 <Text color="error">{'▇'.repeat(Math.max(1, Math.round((n / top) * 12)))}</Text> {n} {k}
1035 </Text>
1036 ))}
1037 </Box>
1038 )}
1039
1040 {/* Claude's widgets and the slots, ranked, each with why it's there on a dim line under it. A widget that hasn't
1041 changed in a while is dim, and says when it did */}
1042 {ranked.map(p => {
1043 const en = entryOf(p, r), line = blockLine(en, cols), stand = (w: typeof en.w) => figuresOf(w, true).find(f => len(f) <= cols) ?? figuresOf(w, true).at(-1)
1044 return (
1045 <Box flexDirection="column">
1046 {line ? (
1047 // the figure in a box that never shrinks: the tail, then the title, give way to the room first
1048 <Box flexDirection="row">
1049 <Box flexShrink={1}><Text dimColor>{line.title}: </Text></Box>
1050 <Box flexShrink={0}><Text dimColor={en.old}>{line.figure}</Text></Box>
1051 {line.why && <Box flexShrink={3}><Text dimColor> {line.why}</Text></Box>}
1052 </Box>
1053 ) : (
1054 // no room for the figure beside its title: the title whole on a line (wrapped, if it must), the fullest figure
1055 // that fits on the next, or the barest wrapped whole
1056 <Box flexDirection="column">
1057 <Text dimColor>{en.title}:</Text>
1058 <Text dimColor={en.old}>{stand(en.w)}</Text>
1059 </Box>
1060 )}
1061 <Text dimColor italic> {cutText(p.why, cols - 2)}</Text>
1062 </Box>
1063 )
1064 })}
1065
1066 {r.fun.length > 0 && (
1067 <Box flexDirection="column" marginTop={1}>
1068 {r.fun.map(f => <Text italic color="claude">{plain(f)}</Text>)}
1069 </Box>
1070 )}
1071
1072 <Box flexDirection="column" marginTop={1}>
1073 {r.log.slice(-room).map(l => {
1074 const stamp = `${clock(l.at - r.startedAt)} `
1075 return (
1076 <Text>
1077 <Text dimColor>{stamp}</Text>
1078 {/* the observer's lines dim; a line with no by is Claude's */}
1079 <Text color={COLOR[l.level] ?? 'text'} dimColor={l.by === 'observer'}>{fitText(l.text, cols - len(stamp))}</Text>
1080 </Text>
1081 )
1082 })}
1083 </Box>
1084 {controls}
1085 {sourceList}
1086 </Box>
1087 )
1088 })
1089}
1090hooks/autolog.ts 88 lines1import type { HudLine } from '../types'
2import { basename, EDIT, heads, host, SHELL, type Call } from './sources/tools'
3import { fit } from './svg'
4
5// The auto-log: while a run is live, what a finished main-loop call did becomes a log line
6// tagged `by: 'observer'`. Pure: it reads a call and the test counts around it, never the call's output beyond those
7// counts. What reaches the log is a name (a command's head, a file, a site), cut by fit(): raw shell output, with its
8// colour codes, paths and quotes, is never a line.
9
10export type Said =
11 | { kind: 'edit'; file: string }
12 | { kind: 'failed' | 'denied'; command: string }
13 | { kind: 'tests'; failed: number; passed?: number; was?: number }
14 | { kind: 'fetch'; site: string }
15 | { kind: 'compact' }
16 | { kind: 'agent'; name: string; state: 'started' | 'finished' | 'failed' | 'lost' }
17
18const CAP = 200
19// A name's room in a log line, px at the 13px the band is drawn in: about 30 characters.
20const ROOM = 220
21const name = (s: string) => fit(s, ROOM, 13)
22
23/**
24 * What one finished main-loop call says. `testsBefore` is the tests source's last failed count and `testsAfter` the
25 * counts of the summary it just took (undefined when it took none), so a summary says something only when the count
26 * moved, or is the first.
27 */
28export function said(c: Call, testsBefore: number | undefined, testsAfter: { passed?: number; failed?: number } | undefined): Said[] {
29 const out: Said[] = []
30 // simplification: a summary with neither count says nothing, as 0 failed would be a number nobody read
31 const failed = testsAfter && (testsAfter.failed ?? testsAfter.passed) !== undefined ? (testsAfter.failed ?? 0) : undefined
32 const tests = failed !== undefined && failed !== testsBefore ? { kind: 'tests' as const, failed, passed: testsAfter?.passed, was: testsBefore } : undefined
33 // a shell run is known by its last step: its exit status is the one the call reports. When the run also gives a
34 // tests line, that line carries the meaning and "failed" is left out
35 const command = SHELL.includes(c.tool) && typeof c.input.command === 'string' ? heads(c.input.command).at(-1) : undefined
36 if (command && c.failed && !tests) out.push({ kind: c.denied ? 'denied' : 'failed', command: name(command) }) // a refused one never ran
37
38 const path = c.input.file_path ?? c.input.notebook_path
39 const file = EDIT.includes(c.tool) && typeof path === 'string' && !c.failed ? name(basename(path)) : ''
40 if (file) out.push({ kind: 'edit', file })
41
42 if (tests) out.push(tests)
43
44 // a search names its query in curly quotes, a fetch its host
45 const query = c.tool === 'WebSearch' && typeof c.input.query === 'string' ? name(c.input.query) : ''
46 const site = query ? `“${query}”`
47 : typeof c.input.url === 'string' && /^https?:/.test(c.input.url) ? name(host(c.input.url) ?? '') : ''
48 if (site && !c.failed) out.push({ kind: 'fetch', site })
49 return out
50}
51
52/** What a subagent's start, finish or failure says: the Agents source (sources/agents.ts) decides which; the name is cut here. */
53export const agentSaid = (a: { name: string; state: 'started' | 'finished' | 'failed' | 'lost' }): Said => ({ kind: 'agent', name: name(a.name), state: a.state })
54
55const words = (s: Said): string => {
56 switch (s.kind) {
57 case 'edit': return `edited ${s.file}`
58 case 'failed': return `${s.command} failed`
59 case 'denied': return `${s.command} denied`
60 case 'tests': return s.was === undefined ? `tests: ${s.failed} failed` : `tests: ${s.was} failed → ${s.failed}`
61 case 'fetch': return s.site.startsWith('“') ? `searched ${s.site}` : `fetched ${s.site}`
62 case 'compact': return 'context compacted'
63 case 'agent': return `agent ${s.state === 'lost' ? 'lost at reload' : s.state}: ${s.name}`
64 }
65}
66// Marks as well as colour: a failed run is a cross, a failing summary a warning, a clean one or a finished agent a tick.
67const level = (s: Said): HudLine['level'] => {
68 switch (s.kind) {
69 case 'failed': return 'err'
70 case 'tests': return s.failed ? 'warn' : 'ok'
71 case 'agent': return s.state === 'failed' ? 'err' : s.state === 'finished' ? 'ok' : s.state === 'lost' ? 'warn' : 'info'
72 default: return 'info'
73 }
74}
75
76// simplification: the newest line's own text is the collapse's memory ("edited svg.ts ×3"), so nothing else is kept
77const EDITED = /^edited (.+?)(?: ×(\d+))?$/
78
79/** The log with one more line from the observer. An edit to the file the newest line (the observer's) is about makes that line ×N. */
80export function write(log: HudLine[], s: Said, now: number): HudLine[] {
81 const last = log.at(-1)
82 if (s.kind === 'edit' && last?.by === 'observer') {
83 const [, file, n] = EDITED.exec(last.text) ?? []
84 if (file === s.file) return [...log.slice(0, -1), { ...last, at: now, text: `edited ${file} ×${Number(n ?? 1) + 1}` }]
85 }
86 return [...log, { at: now, text: words(s), level: level(s), by: 'observer' as const }].slice(-CAP)
87}
88hooks/bind.ts 164 lines1import type { HudBind, HudExtractor, HudRun, HudWidget, HudWidgetType } from '../types'
2import { extract, type Reading } from './extract'
3import { life, PLAN_TOOLS } from './lifecycle'
4import { EDIT, heads, host, SHELL, type Call } from './sources/tools'
5
6// Bindings: a widget Claude binds to its own calls is filled by the observer from
7// each matching call of the main loop, through the extractors. Pure: the run and a finished call in, the run out.
8// A call that matches and parses is a reading (the widget's value, `by: 'observer'`, its clock stamped); one that matches
9// and doesn't parse is a miss, and a bound widget's age is its misses in a row (lifecycle.ts). Stale, it is drawn as "no
10// reading", never its old number (unread()).
11
12export type Bind = HudBind
13export const ON: readonly HudBind['on'][] = ['shell', 'edit', 'fetch', 'measure', 'agents', 'plan']
14export const EXTRACTORS: readonly HudExtractor[] = ['pass_fail', 'n_of_m', 'percent', 'duration', 'last_number', 'exit']
15const MATCHED: readonly string[] = ['shell', 'edit', 'fetch']
16/** The widgets one reading can fill: a value, a point, a line of text. A list, a grid or a checklist holds no one number. */
17export const READS: readonly HudWidgetType[] = ['stat', 'gauge', 'ring', 'meter', 'line', 'text']
18/** What session.measure is, as a call a binding can match (no tool goes by this name). */
19export const MEASURE = 'session.measure'
20/**
21 * Programs that run for anything: a shell match that is only one of them reads every call of it (a `curl` of any URL, a
22 * `tail` of any log), and heads() keeps no flag, path or URL to tell one from another. validBind() refuses them, and the
23 * answer says to bind a script of your own. A script is known by its name (`./health-check`, `bash health-check`; a name
24 * with a dot is read only when it is run itself, as heads() keeps no word with a dot).
25 * simplification: a list, so a reader not on it still matches broadly, and a match with a word beside it (`ssh uptime`) is
26 * narrower and stands. Add a reader here when one shows up; a real fix would read the whole command, not its head.
27 */
28export const READERS: readonly string[] = ['curl', 'wget', 'tail', 'head', 'cat', 'echo', 'printf', 'grep', 'rg', 'jq', 'ssh']
29
30/** A binding as Claude sent it, checked: only `on`, `match` and `extract`, with `match` text where it's needed and absent where it isn't. */
31export function validBind(b: unknown): { bind: HudBind } | { why: string } {
32 if (typeof b !== 'object' || b === null || Array.isArray(b)) return { why: 'it must be { on, match, extract }' }
33 const { on, match, extract: by } = b as Record<string, unknown>
34 if (!ON.includes(on as HudBind['on'])) return { why: `on must be one of ${ON.join(', ')}` }
35 if (!EXTRACTORS.includes(by as HudExtractor)) return { why: `extract must be one of ${EXTRACTORS.join(', ')}` }
36 const named = MATCHED.includes(on as string)
37 if (named && (typeof match !== 'string' || !match.trim())) return { why: `match must be text for ${on}` }
38 if (!named && match !== undefined) return { why: `${on} takes no match` }
39 // a program that runs for anything would read every call of it, and what it prints is anyone's data
40 const program = on === 'shell' ? (match as string).trim().toLowerCase() : ''
41 if (READERS.includes(program)) return { why: `${program} runs for anything; bind a script of your own, like health-check` }
42 // a match the HUD could never see would never read, never age, and keep the general check away for the whole run:
43 // a shell run is known by its program and up to two verbs (heads()), a site by its host
44 if (on === 'shell' && !heads(match as string).some(h => h.includes(match as string))) return { why: 'match must be a command name, like pytest or npm test' }
45 if (on === 'fetch' && /[/:]/.test(match as string)) return { why: 'match must be a host, like example.com' }
46 return { bind: { on: on as HudBind['on'], ...(named ? { match: match as string } : {}), extract: by as HudExtractor } }
47}
48
49const NONE = { hits: 0, misses: 0, seen: 0 }
50// What Claude sends as a reading of its own: a value, points (or one pushed) or text.
51const VALUES = ['value', 'points', 'push', 'text']
52// What a reading wrote, which goes when nobody stands behind it any more.
53const READ = ['value', 'points', 'text', 'unit', 'tone'] as const
54/**
55 * A widget Claude just sent (`sent`, as it came in), merged with what it held (`was`, before the merge). A `bind` there
56 * replaces the binding; with none, the one it has stays. A widget is fresh only with a reading someone stands behind:
57 * its misses start over only on a reading, a value of Claude's (`VALUES`) or a changed binding (whose counts start from
58 * none: a new command's misses are its own), and the observer's last reading goes when Claude changes the binding or
59 * updates the widget without a value of its own (it is drawn "no reading" until the next). The same binding sent again,
60 * or a title, keeps the misses: a stale widget stays stale, and named.
61 * A binding that can't stand, or a widget that takes no reading, leaves it unbound, and `why` says so.
62 */
63export function rebind(w: HudWidget, was: HudWidget | undefined, sent: Record<string, unknown>): { w: HudWidget; why?: string } {
64 const valued = VALUES.some(k => k in sent)
65 if (was?.bind && was.by === 'observer' && !valued) {
66 w = { ...w }
67 for (const k of READ) if (!(k in sent)) delete w[k]
68 }
69 const v = 'bind' in sent ? validBind(sent.bind) : w.bind ? { bind: w.bind } : undefined
70 if (!v) return { w }
71 if ('bind' in v && READS.includes(w.type)) {
72 // a changed binding (or a first one) starts over: its counts from none, ageing from zero under it
73 const same = w.bind?.on === v.bind.on && w.bind.match === v.bind.match && w.bind.extract === v.bind.extract
74 return { w: { ...w, bind: v.bind, bound: !same ? NONE : { ...NONE, ...w.bound, ...(valued ? { misses: 0 } : {}) } } }
75 }
76 const { bind, bound, ...rest } = w
77 return { w: rest, why: 'why' in v ? v.why : `a ${w.type} takes no reading (stat, gauge, ring, meter, line or text do)` }
78}
79
80/** Whether a finished call is one the binding reads. Plain text throughout: a substring of a shell step's head, a path's end, a host's end. */
81export function matches(b: HudBind, c: Call): boolean {
82 const m = b.match ?? ''
83 switch (b.on) {
84 case 'shell': return SHELL.includes(c.tool) && typeof c.input.command === 'string' && heads(c.input.command).some(h => h.includes(m))
85 case 'edit': {
86 const path = c.input.file_path ?? c.input.notebook_path
87 return EDIT.includes(c.tool) && typeof path === 'string' && path.replaceAll('\\', '/').endsWith(m.replaceAll('\\', '/'))
88 }
89 case 'fetch': return typeof c.input.url === 'string' && /^https?:/.test(c.input.url) && (host(c.input.url)?.endsWith(m.toLowerCase()) ?? false)
90 case 'agents': return c.tool === 'Agent'
91 case 'plan': return PLAN_TOOLS.includes(c.tool)
92 case 'measure': return c.tool === MEASURE
93 default: return false
94 }
95}
96
97// A reading in words, for a stat's unit and a text widget: "of 938", "failed, 39 passed", "%".
98const unitOf = (g: Reading) => (g.total !== undefined ? `of ${g.total}` : g.passed !== undefined ? `failed, ${g.passed} passed` : g.unit)
99const outcome = (by: HudExtractor, g: Reading) => (by === 'pass_fail' || by === 'exit' ? { tone: g.value > 0 ? 'bad' as const : 'good' as const } : {})
100
101/** The widget with a reading in it: a line takes a point, a text the reading in words, the rest the value. Claude's change (delta) no longer says how it moved. */
102function fill(w: HudWidget, by: HudExtractor, g: Reading): HudWidget {
103 const point = (pts: number[] | undefined) => [...(pts ?? []), g.value].slice(-200)
104 const u = unitOf(g)
105 switch (w.type) {
106 case 'line': return { ...w, points: point(w.points), ...outcome(by, g) }
107 case 'text': return { ...w, text: `${g.value}${!u ? '' : u === '%' ? u : ' ' + u}`, ...outcome(by, g) }
108 case 'stat': return { ...w, value: g.value, unit: u ?? w.unit, delta: undefined, points: w.points && point(w.points), ...outcome(by, g) }
109 default: return { ...w, value: g.value, max: g.total ?? w.max, unit: g.total === undefined && g.passed === undefined ? g.unit ?? w.unit : w.unit, delta: undefined, ...outcome(by, g) }
110 }
111}
112
113/** How many shell runs a run keeps in `ran`. */
114export const RAN = 200
115/**
116 * A finished call of the main loop (not one denied) seen by the run: its shell head kept for the pointed check (`ran`),
117 * and each bound widget it matches read. A reading stamps the widget's clock as the observer's and frees it to be named
118 * again; a miss counts, and ages it. The run itself when nothing changed.
119 */
120// `n`: the session's main-loop calls, stamped on a widget a reading fills (the ranker's recency, hooks/rank.ts).
121export function observe(r: HudRun, c: Call, now: number, n?: number): HudRun {
122 const whole = SHELL.includes(c.tool) && typeof c.input.command === 'string' ? heads(c.input.command).at(-1) : undefined
123 const head = whole && whole.length > 30 ? whole.slice(0, 29) + '…' : whole // kept as the pointed check names it
124 let named = r.named, touched = false
125 const widgets = (r.widgets ?? []).map(w => {
126 const v = w.bind && validBind(w.bind)
127 if (!v || 'why' in v || !matches(v.bind, c)) return w
128 touched = true
129 const got = extract(v.bind.extract, c.text ?? '', c.failed), was = { ...NONE, ...w.bound }
130 const bound = { hits: was.hits + (got ? 1 : 0), misses: got ? 0 : was.misses + 1, seen: was.seen + 1 }
131 if (!got) return { ...w, bound }
132 named = named?.filter(id => id !== w.id)
133 return { ...fill(w, v.bind.extract, got), bound, at: now, by: 'observer', tick: r.ticks ?? 0, staleAt: undefined, n }
134 })
135 if (!touched && !head) return r
136 return { ...r, widgets, named, ...(head ? { ran: [...(r.ran ?? []), { tick: r.ticks ?? 0, head }].slice(-RAN) } : {}) }
137}
138
139/** What a binding on the task list reads: its done of total. */
140export const planText = (items: { state: string }[]) => `${items.filter(i => i.state === 'done').length}/${items.length}`
141/** What a binding on the measure reads: the context window's fill, when it changed. */
142export const measureText = (e: { context: { tokens?: number; window: number; percent?: number }; changed: readonly string[] }) =>
143 e.changed.includes('context') && e.context.percent !== undefined
144 ? `${e.context.tokens === undefined ? '' : `${e.context.tokens} of ${e.context.window} tokens, `}${e.context.percent}%`
145 : undefined
146
147// A bound widget with nothing to show: stale, or with no reading yet (or none since Claude changed it).
148const unreadable = (w: HudWidget, r: HudRun) =>
149 !!w.bind && (life(w, r) === 'stale' || (w.type === 'line' ? !w.points?.length : w.type === 'text' ? !w.text : w.value === undefined))
150/**
151 * The run as it is drawn: a bound widget gone stale, or with no reading anyone stands behind, says "no reading", never
152 * an old number. It stays in its place, a stat with its title and clock (so a stale one draws dim, with when it last
153 * read, and its why counts from that reading) and nothing else of its own.
154 */
155export function unread<R extends HudRun | null>(r: R): R {
156 if (!r || !(r.widgets ?? []).some(w => unreadable(w, r))) return r
157 return {
158 ...r,
159 widgets: r.widgets.map(w => (unreadable(w, r)
160 ? { id: w.id, type: 'stat' as const, title: w.title, span: w.span, value: 'no reading', at: w.at, by: w.by, tick: w.tick, staleAt: w.staleAt, bind: w.bind, bound: w.bound, n: w.n }
161 : w)),
162 } as R
163}
164hooks/board.ts 29 lines1import type { HudDeclaration, HudHeld } from '../types'
2import * as agents from './sources/agents'
3import * as meter from './sources/meter'
4import * as testRuns from './sources/tests'
5import * as toolSignals from './sources/tools'
6
7// The slots in their declared order, which the ranker breaks ties by: the HUD's own first, the ones that most need
8// seeing first, then each plugin's in the order it declared them, plugins in the order they declared.
9export const OWN = ['tests', 'failing', 'agents', 'agents-running', 'limits', 'context', 'cost', 'edited', 'fetched']
10export const inOrder = (held: Record<string, HudHeld>, decls: Record<string, HudDeclaration>): HudHeld[] => {
11 const senders = ['hud', ...Object.keys(decls)]
12 const place = (s: HudHeld) => [senders.indexOf(s.from), s.from === 'hud' ? OWN.indexOf(s.id) : decls[s.from]?.slots.findIndex(d => d.id === s.id) ?? 0]
13 return Object.values(held).sort((a, b) => { const [p = 0, q = 0] = place(a), [x = 0, y = 0] = place(b); return p - x || q - y })
14}
15
16// Who sends, as the person sees and mutes them: the HUD's own four by name, then each plugin
17// that declared itself.
18export type Source = { key: string; name: string; access: string; from: string; ids: string[]; slots: string[] }
19export const slug = (s: string) => s.trim().toLowerCase().replace(/\s+/g, '-')
20export const sourcesOf = (decls: Record<string, HudDeclaration>): Source[] =>
21 [...[testRuns.declaration, toolSignals.declaration, meter.declaration, agents.declaration].map(d => ['hud', d] as const), ...Object.entries(decls)].map(([from, d]) => ({
22 key: from === 'hud' ? `hud:${slug(d.name)}` : from, name: d.name, access: d.access, from, ids: d.slots.map(s => s.id), slots: d.slots.map(s => s.title),
23 }))
24export const isMuted = (s: HudHeld, list: Source[], off: readonly string[]) => list.some(src => off.includes(src.key) && src.from === s.from && src.ids.includes(s.id))
25
26// /hud sources, /hud mute <name>, /hud unmute <name>: a plugin by its name, the HUD's own by theirs ("test results").
27export const whose = (s: Source) => (s.from === 'hud' ? 'HUD' : `from ${s.from}`)
28export const typed = (s: Source) => (s.from === 'hud' ? slug(s.name) : s.key)
29hooks/lifecycle.ts 124 lines1import type { HudClock, HudLine, HudRun } from '../types'
2import { fit, measure, plain } from './text'
3
4// The strip draws on the terminal and the desktop app (VS Code and the phone draw no AbovePrompt), and the
5// desktop app detaches a session it stops showing: so a HUD is seen while one of these is attached.
6export const DRAWS: readonly string[] = ['terminal', 'desktop']
7
8// simplification: here, not in run.ts (which re-exports it), so run.ts can import boundary() without a cycle
9export const isLive = (r: HudRun | null): r is HudRun => r !== null && r.status !== 'done' && r.status !== 'failed'
10
11// A span in words, for "updated … ago": a clock reading there looks like a time of day.
12export const span = (ms: number) => {
13 const m = Math.max(1, Math.floor(ms / 60_000))
14 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`
15}
16
17// How long a live HUD has gone without an update while Claude kept working: shown once past a minute.
18export const quietFor = (r: HudRun, now: number) => {
19 const ms = now - (r.updatedAt ?? r.startedAt)
20 return isLive(r) && (r.sinceUpdate ?? 0) > 0 && ms >= 60_000 ? ms : undefined
21}
22
23// Clocks. Every widget and tile carries when it last changed and the run's ticks then (main-loop tool calls, so a
24// quiet session ages nothing and a subagent's calls age nothing). Unchanged for AGING calls it dims; for STALE it is
25// named once to Claude; stale across RETIRE_BOUNDARIES boundaries it leaves the board.
26// A bound widget (hooks/bind.ts) ages only on the calls it matches: its age is the misses in a row since its last
27// reading, so it waits for its own command however long the work goes elsewhere.
28export const AGING = 12, STALE = 24, RETIRE_BOUNDARIES = 2
29export type Life = 'fresh' | 'aging' | 'stale'
30type Clocked = { tick?: number; bind?: unknown; bound?: { misses: number } }
31/** The main-loop calls since it last changed. */
32export const idle = (w: { tick?: number }, r: HudRun) => (r.ticks ?? 0) - (w.tick ?? r.ticks ?? 0)
33export const age = (w: Clocked, r: HudRun) => (w.bind ? w.bound?.misses ?? 0 : idle(w, r))
34export function life(w: Clocked, r: HudRun): Life {
35 const n = age(w, r)
36 return n >= STALE ? 'stale' : n >= AGING ? 'aging' : 'fresh'
37}
38
39// The plan the observer mirrors from the task-list tool (hooks/plan.ts) is the widget with this id that the observer
40// owns; a widget of Claude's by the same id is Claude's, a bound one too, though the observer fills it.
41// PLAN_TOOLS are the tools that carry the list: TaskStop stops a background shell, and TaskGet reads one item.
42export const PLAN = 'plan'
43export const PLAN_TOOLS: readonly string[] = ['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList']
44export const isMirror = (w: { id: string; by?: string; bind?: unknown }) => w.id === PLAN && w.by === 'observer' && !w.bind
45
46// The checklist that stands in for phases when a run has neither progress nor phases of Claude's: the first with
47// items, Claude's before the plan mirrored from the task-list tool (whose progress is not Claude's).
48export const leadList = (r: HudRun) => {
49 if ((r.progress && r.progressBy !== 'observer') || r.phases.length) return undefined
50 const lists = (r.widgets ?? []).filter(w => w.type === 'checklist' && (w.items?.length ?? 0) > 0)
51 return lists.find(w => !isMirror(w)) ?? lists[0]
52}
53
54// The mirror's progress (done ÷ total, HudRun.progressBy) stands only while the mirror leads: Claude's progress, phases
55// or checklist take the lead and the figure goes with the plan (two plans' counts side by side would read as one); it
56// returns if Claude's lead goes. Run after anything that changes the widgets, the phases or Claude's progress.
57export const settle = (r: HudRun): HudRun => {
58 if (r.progress && r.progressBy !== 'observer') return r
59 const lead = leadList(r), items = lead && isMirror(lead) ? lead.items ?? [] : []
60 if (!items.length) return r.progressBy === 'observer' ? { ...r, progress: undefined, progressBy: undefined } : r
61 return { ...r, progress: { n: items.filter(i => i.state === 'done').length, total: items.length, label: 'steps' }, progressBy: 'observer' }
62}
63
64// A phase boundary: a phase change, a rebuild, or the main loop's turn ending. What was stale before it is marked
65// with the boundaries passed until then; what has been stale across RETIRE_BOUNDARIES of them goes, and the log
66// says so in the observer's voice.
67export function boundary(r: HudRun, now: number): HudRun {
68 const was = r.boundaries ?? 0, boundaries = was + 1, gone: string[] = []
69 const mark = <T extends { tick?: number; staleAt?: number }>(x: T, name: string): T | undefined => {
70 const staleAt = x.staleAt ?? (life(x, r) === 'stale' ? was : undefined)
71 if (staleAt !== undefined && boundaries - staleAt >= RETIRE_BOUNDARIES) {
72 gone.push(name)
73 return undefined
74 }
75 return staleAt === x.staleAt ? x : { ...x, staleAt }
76 }
77 const widgets = (r.widgets ?? []).flatMap(w => mark(w, w.title ?? w.id) ?? [])
78 const tiles = { ...r.tiles }, tileClock: Record<string, HudClock> = {}
79 for (const [k, c] of Object.entries(r.tileClock ?? {})) {
80 const kept = mark(c, k)
81 if (kept) tileClock[k] = kept
82 else delete tiles[k]
83 }
84 const said: HudLine[] = gone.map(name => ({ at: now, text: `Retired ${name}`, level: 'info', by: 'observer' }))
85 return settle({ ...r, boundaries, widgets, tiles, tileClock, log: [...r.log, ...said].slice(-200) })
86}
87
88// The clock an update stamps on what Claude sent.
89export const stamp = (r: HudRun, now: number): HudClock => ({ at: now, by: 'claude', tick: r.ticks ?? 0 })
90
91// Auto-run: with no live run, a main-loop turn (whatever began it) that reaches AUTO_CALLS calls, or runs
92// AUTO_MS, starts a run of its own while someone watches. A live run with no turn for PAUSE_MS reads paused.
93export const AUTO_CALLS = 8, AUTO_MS = 60_000, PAUSE_MS = 30 * 60_000
94export const WAITING = 'waiting for you', PAUSED = 'paused'
95
96export const shouldStart = (live: boolean, watching: boolean, turn: { startedAt: number; calls: number } | undefined, now: number): boolean =>
97 !live && watching && turn !== undefined && (turn.calls >= AUTO_CALLS || now - turn.startedAt >= AUTO_MS)
98
99// A run that starts on its own is titled from the prompt's first line, plain, fitted to the title's room (60
100// characters at 13px); 'Task' when there is none.
101// simplification: the room is that line's own first 60 characters, so a line of 60 stays whole and a longer one is cut at a word.
102// The first line is taken before plain(), and 2,000 characters of it: plain() is quadratic on a long run of "[".
103export function titleOf(prompt: string | undefined): string {
104 const first = (prompt ?? '').split('\n').map(l => l.trim()).find(Boolean)
105 const line = first && plain(first.slice(0, 2000)).trim()
106 return (line && fit(line, measure(line.slice(0, 60), 13), 13)) || 'Task'
107}
108
109// A background task's notification, as the prompt that carries it (proved on desktop): which job, and how it ended
110// (completed, failed, killed).
111export function notification(text: string): { id: string; status: string } | undefined {
112 const block = /<task-notification>([\s\S]*?)(?:<\/task-notification>|$)/.exec(text)?.[1] ?? ''
113 const id = /<task-id>\s*([^<]*?)\s*<\/task-id>/.exec(block)?.[1], status = /<status>\s*([^<]*?)\s*<\/status>/.exec(block)?.[1]
114 return id && status ? { id, status } : undefined
115}
116
117// The main loop's background jobs still running: shells, and subagents started in the background (both are in `jobs`).
118export const jobsRunning = (s: { jobs?: Record<string, 'shell' | 'agent'> }) => Object.keys(s.jobs ?? {}).length
119
120// The 1-second clock's question: a live run, no turn for PAUSE_MS (from the last sign of one, else the run's own last
121// update, as a run from before auto-run has no lastTurnAt), and no job still at work.
122export const pauses = (r: HudRun | null, lastTurnAt: number | undefined, jobs: number, now: number): boolean =>
123 isLive(r) && r.status !== PAUSED && jobs === 0 && now - (lastTurnAt ?? r.updatedAt ?? r.startedAt) >= PAUSE_MS
124hooks/modes.ts 44 lines1import type { HudMode } from '../types'
2
3// The five modes, as data: the slots each puts first, after the
4// person's pins and Claude's focus. The ranker reads this; nothing else decides what a mode means.
5export type ModeName = HudMode
6export const MODES: Record<ModeName, { label: string; slots: string[] }> = {
7 build: { label: 'Build', slots: ['hud/tests', 'hud/failing', 'hud/edited'] },
8 // tests and failing runs, not context: Debug mode places context by its score (the worked example in rank.test.ts)
9 debug: { label: 'Debug', slots: ['hud/tests', 'hud/failing'] },
10 review: { label: 'Review', slots: ['hud/edited', 'hud/tests'] },
11 research: { label: 'Research', slots: ['hud/fetched', 'hud/context'] },
12 plan: { label: 'Plan', slots: ['hud/plan', 'hud/agents-running', 'hud/agents'] },
13}
14export const MODE_NAMES = Object.keys(MODES) as ModeName[]
15
16// What each is for, in the words the person and Claude read (the tool's property description, and /hud mode).
17export const MODE_LINES: Record<ModeName, string> = {
18 build: 'writing code: tests, failing commands, edited files',
19 debug: 'chasing a failure: tests, failing commands',
20 review: 'reading a change: edited files, tests',
21 research: 'reading and searching: sites fetched, context',
22 plan: 'planning: the plan, agents running',
23}
24
25// A mode named by Claude or the person: any case, nothing else.
26export const modeOf = (v: unknown): ModeName | undefined => (typeof v === 'string' ? MODE_NAMES.find(m => m === v.trim().toLowerCase()) : undefined)
27
28// "Mode: Debug (set by you)": the person's holds for the session; a mode that isn't the default is Claude's pick.
29export const modeLine = (mode: ModeName, byPerson: boolean) =>
30 `Mode: ${MODES[mode].label}${byPerson ? ' (set by you)' : mode === 'build' ? '' : " (Claude's pick)"}`
31
32// /hud mode, /hud mode <name>: no name lists the five and the current one.
33export function modeCommand(arg: string, mode: ModeName, byPerson: boolean): { mode?: ModeName; text: string } {
34 const name = arg.trim()
35 if (!name) return { text: [modeLine(mode, byPerson), ...MODE_NAMES.map(m => ` ${m}: ${MODE_LINES[m]}`), 'Switch with /hud mode <name>.'].join('\n') }
36 const m = modeOf(name)
37 if (!m) return { text: `No mode "${name}". Name one of: ${MODE_NAMES.join(', ')}.` }
38 return { mode: m, text: `Mode is ${MODES[m].label} for this session; Claude's mode no longer applies.` }
39}
40
41// Claude's focus: up to FOCUS_MAX slot keys that matter now. Anything but a string is junk; a repeat counts once.
42export const FOCUS_MAX = 3
43export const focusOf = (raw: unknown[]): string[] => [...new Set(raw.filter((k): k is string => typeof k === 'string').map(k => k.trim()).filter(Boolean))]
44hooks/notes.ts 106 lines1import type { HudRun, HudWidget } from '../types'
2import { RAN } from './bind'
3import { idle, isMirror, life } from './lifecycle'
4
5// A live HUD goes stale when Claude keeps working without updating it: a dozen tool calls with nothing sent. Counted
6// again from the last HUD check, so each one is a fresh window. A check Claude leaves unanswered (no hud call before the
7// next window closes) doubles the next window, to 48 calls at most; any hud call starts over at 12 (apply() clears
8// nudged). Time alone is no drift: a remote job's few long calls are not a quiet HUD.
9export const DRIFT_CALLS = 12
10export const DRIFT_MAX_CALLS = 48
11export function stale(r: HudRun): boolean {
12 const twice = 2 ** (r.nudged?.n ?? 0)
13 return (r.sinceUpdate ?? 0) - (r.nudged?.calls ?? 0) >= Math.min(DRIFT_CALLS * twice, DRIFT_MAX_CALLS)
14}
15
16// Claude's own widgets: every one but the plan the observer mirrors, a bound one too (the observer fills it; it is
17// Claude's to keep, rebind or remove).
18const claudes = (r: HudRun) => (r.widgets ?? []).filter(w => !isMirror(w))
19/**
20 * Whether any widget fills itself from calls it has seen (a reading or a miss: `bound.seen`): then the general check
21 * never goes, only the pointed one. A binding whose command never ran has proved nothing (it never ages, so it is never
22 * named), so until then the run is checked as if nothing were bound.
23 */
24export const isBound = (r: HudRun) => (r.widgets ?? []).some(w => w.bind && (w.bound?.seen ?? 0) > 0)
25
26// What the dashboard shows, in one line Claude can act on (ids to remove, fields to send).
27export function shows(r: HudRun): string {
28 const phaseNow = r.phases.find(p => p.state === 'now')?.name
29 const phasesDone = r.phases.filter(p => p.state === 'done').length
30 const sent = claudes(r)
31 const parts = [
32 // the observer's progress and widgets (the plan mirror) are not Claude's to send or remove, and cost it no tokens
33 r.progress && r.progressBy !== 'observer' && `progress ${r.progress.n}/${r.progress.total}${r.progress.label ? ' ' + r.progress.label : ''}`,
34 r.phases.length > 0 && `phases ${phasesDone}/${r.phases.length} done${phaseNow ? `, now "${phaseNow}"` : ''}`,
35 Object.keys(r.tiles).length > 0 && `stats ${Object.keys(r.tiles).join(', ')}`,
36 r.cells.length > 0 && `grid of ${r.cells.length}`,
37 r.points.length > 0 && 'trend',
38 Object.keys(r.counts).length > 0 && `counts ${Object.keys(r.counts).join(', ')}`,
39 sent.length > 0 && `widgets ${sent.map(w => `${w.id} (${w.type})`).join(', ')}`,
40 ].filter((p): p is string => typeof p === 'string')
41 const line = parts.length ? parts.join('; ') : 'nothing but the clock'
42 return line.length > 400 ? line.slice(0, 399) + '…' : line
43}
44
45export const about = (r: HudRun) => `"${r.title}"${r.goal ? ` (goal: ${r.goal})` : ''}`
46
47// Read after a tool result once the HUD has gone quiet and !isBound(): 160 characters at most, naming nothing
48// but the count (what it shows is the steer's to say, beside each message).
49export const nudge = (r: HudRun) =>
50 `HUD check: ${r.sinceUpdate ?? 0} tool calls since your last HUD update. Send what moved or bind it; rebuild: true if the work changed; done or fail if finished.`
51
52// Read after a tool result when widgets of Claude's have gone stale (a bound one too, by its misses): named once each, by
53// id and type, with the calls since any of them changed and the shell head run most in those calls, in 160 characters
54// at most (ids cut to 40, heads to 30). What ran is said only while `ran` reaches back over every call it counts, and
55// left out when the note would run past 160; widgets that don't fit are named on the next check.
56const POINTED = 160
57const cut = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
58function busiest(r: HudRun, since: number): { head: string; k: number } | undefined {
59 const ran = r.ran ?? []
60 if (ran.length >= RAN && ran[0]!.tick - 1 > since) return undefined // a call it counts has gone from the record
61 const k = new Map<string, number>()
62 for (const x of ran) if (x.tick > since) k.set(x.head, (k.get(x.head) ?? 0) + 1)
63 const [head, n] = [...k].sort((a, b) => b[1] - a[1])[0] ?? []
64 return head && n ? { head, k: n } : undefined
65}
66function pointedText(r: HudRun, ws: HudWidget[]): string {
67 const one = ws.length === 1, n = Math.min(...ws.map(w => idle(w, r))), busy = busiest(r, (r.ticks ?? 0) - n)
68 const said = `HUD check: your ${ws.map(w => `${cut(w.id, 40)} ${w.type}`).join(', ')} ${one ? "hasn't" : "haven't"} changed in ${n} call${n === 1 ? '' : 's'}`
69 const end = `. Update, bind or remove ${one ? 'it' : 'them'}.`
70 const ran = busy ? ` while you ran ${busy.k} ${cut(busy.head, 30)} command${busy.k === 1 ? '' : 's'}` : ''
71 return (said + ran + end).length <= POINTED ? said + ran + end : said + end
72}
73export function pointedIds(r: HudRun): string[] {
74 const due = claudes(r).filter(w => life(w, r) === 'stale' && !(r.named ?? []).includes(w.id))
75 const ws: HudWidget[] = []
76 for (const w of due) if (!ws.length || pointedText(r, [...ws, w]).length <= POINTED) ws.push(w)
77 return ws.map(w => w.id)
78}
79export function pointed(r: HudRun): string | undefined {
80 const ids = pointedIds(r)
81 return ids.length ? pointedText(r, claudes(r).filter(w => ids.includes(w.id))) : undefined
82}
83
84// Read beside a message that arrives while a run is live: the goal (else the title) and the ids of the slots the strip
85// shows, which Claude may name in `focus`. 160 characters (40 tokens) at most: the goal is cut at 60, and an id with no
86// room is left out, not cut. Ids only, never a slot's value, note, title or items, so a source's words don't reach
87// Claude here (the skill section tells it what to do with a message).
88const STEER = 160
89export function steer(r: HudRun, keys: readonly string[] = []): string {
90 const goal = (r.goal || r.title).replace(/\s+/g, ' ').trim()
91 const head = `HUD: "${goal.length > 60 ? goal.slice(0, 59) + '…' : goal}".`
92 const named = (ids: string[]) => `${head} Focus ids: ${ids.join(', ')}.`
93 const ids: string[] = []
94 for (const k of keys) if (named([...ids, k]).length <= STEER) ids.push(k)
95 return ids.length ? named(ids) : head
96}
97
98// Read once, after the tool result that follows a run starting on its own: 40 tokens or fewer for a 60-character title.
99export const autoNote = (title: string) => `HUD: a run "${title}" started on its own. Name it with title and goal when you know them.`
100
101// Read beside a message while no run is live and someone is watching.
102// simplification: KICKOFF goes (its prompt.submit branch and its gate test too) once a run that starts on its own covers 95%
103// of long turns with Claude's HUD calls taken out (46.2% at 2.0.0); until then it stays, and the skill (hooks/skill.ts)
104// says the same once, cached, beside it.
105export const KICKOFF = 'If this task will take more than about a minute of tool calls (builds, migrations, multi-file refactors, retry loops, remote jobs), start the HUD first (start: a short title, and goal), keep it current as the work moves, and finish it with done or fail. Do the same when the person asks for a HUD or a dashboard. Skip it for quick answers and one-shot edits.'
106hooks/plan.ts 89 lines1import type { HudRun, HudState, HudWidget } from '../types'
2import { isMirror, PLAN, PLAN_TOOLS, settle } from './lifecycle'
3import { asObject } from './run'
4import { plain } from './svg'
5
6// The plan mirror: a finished call of the main loop's task-list tool becomes an
7// observer-owned checklist with the id `plan`, and its done ÷ total the run's progress when Claude sent none. Pure: it
8// reads a call and its result, never the model's words beyond the items' labels. Built against the tool types
9// (.claude-plugin/types/claude-code-tools); no session of this repo has had the tool, so it is unverified in the app.
10
11export type PlanItem = { id: string; label: string; state: HudState }
12
13/** The tools that carry the list (lifecycle.ts, where hooks/bind.ts reads them too). */
14export const TOOLS = PLAN_TOOLS
15
16// A `Map`, not an object: a status of "constructor" must not find a function.
17const STATE = new Map<unknown, HudState>([['pending', 'todo'], ['in_progress', 'now'], ['completed', 'done']])
18const LABEL = 40 // the gate's cap on a label (sources/contract.ts)
19
20// One line of plain words, at most the gate's 40, cut where a reader can tell.
21const label = (v: unknown): string | undefined => {
22 if (typeof v !== 'string') return undefined
23 const s = plain(v).replace(/[\u0000-\u001f\u007f]+/g, ' ').trim()
24 return (s.length > LABEL ? s.slice(0, LABEL - 1).trimEnd() + '…' : s) || undefined
25}
26const idOf = (v: unknown) => (typeof v === 'string' || typeof v === 'number' ? String(v) : undefined)
27// A list the tool sent, or the text of one; anything else is not a list.
28const listOf = (v: unknown): unknown[] | undefined => {
29 try {
30 const l = typeof v === 'string' ? JSON.parse(v) : v
31 return Array.isArray(l) ? l : undefined
32 } catch { return undefined }
33}
34
35/**
36 * The items after one finished task-list call, given the items before it. `undefined` when there is nothing to mirror:
37 * another tool, a list or a result that isn't there, a refused update, or an item this list never held. `input` is the
38 * call's arguments, spread flat on the hook's event; `result` is what the tool returned.
39 */
40export function mirror(items: PlanItem[], tool: string, input: Record<string, unknown>, result: unknown): PlanItem[] | undefined {
41 switch (tool) {
42 case 'TodoWrite': {
43 const todos = listOf(input.todos)
44 // the items have no ids of their own: an item's place in the list is its id
45 return todos?.flatMap((t, i) => {
46 const o = asObject(t), text = label(o?.content)
47 return text ? [{ id: String(i), label: text, state: STATE.get(o?.status) ?? 'todo' }] : []
48 })
49 }
50 case 'TaskCreate': {
51 const task = asObject(asObject(result)?.task), id = idOf(task?.id), text = label(input.subject) ?? label(task?.subject)
52 return id && text ? [...items.filter(x => x.id !== id), { id, label: text, state: 'todo' }] : undefined
53 }
54 case 'TaskUpdate': {
55 const id = idOf(input.taskId)
56 if (!id || !items.some(x => x.id === id) || asObject(result)?.success === false) return undefined
57 if (input.status === 'deleted') return items.filter(x => x.id !== id)
58 return items.map(x => (x.id === id ? { ...x, label: label(input.subject) ?? x.label, state: STATE.get(input.status) ?? x.state } : x))
59 }
60 case 'TaskList': {
61 const tasks = asObject(result)?.tasks
62 return Array.isArray(tasks)
63 ? tasks.flatMap(t => {
64 const o = asObject(t), id = idOf(o?.id), text = label(o?.subject)
65 return id && text ? [{ id, label: text, state: STATE.get(o?.status) ?? 'todo' }] : []
66 })
67 : undefined
68 }
69 default: return undefined
70 }
71}
72
73/**
74 * The run with the plan in it: the observer's checklist, stamped with the run's clock, and, while the plan leads (Claude
75 * has no progress, phases or checklist of its own), the progress done ÷ total (lifecycle.ts settle()). A widget of
76 * Claude's by the id `plan` is Claude's: the run is left as it is. No items takes the plan and its progress away.
77 * Whether the plan is drawn is the drawing's to say (leadList()): it leads only where Claude has no lead of its own.
78 */
79export function intoRun(r: HudRun, items: PlanItem[], now: number, n?: number): HudRun {
80 const widgets = r.widgets ?? [], held = widgets.find(w => w.id === PLAN)
81 if (held && !isMirror(held)) return r
82 const plan: HudWidget = {
83 id: PLAN, type: 'checklist', title: 'Plan', items: items.map(({ label, state }) => ({ label, state })),
84 by: 'observer', at: now, tick: r.ticks ?? 0, n,
85 }
86 const next = !items.length ? widgets.filter(w => w !== held) : held ? widgets.map(w => (w === held ? plan : w)) : [...widgets, plan]
87 return settle({ ...r, widgets: next })
88}
89hooks/prefs.ts 53 lines1import type { HudHeld, HudRun } from '../types'
2import { isMirror } from './lifecycle'
3
4// The person's pins and hides: pinned slots stay first, in pin order; hidden ones never show.
5// Kept by slot key in $.store (`prefs`) and mirrored in $.state. A key is in one list at most, and only the person
6// writes them: Claude can only focus (hooks/modes.ts).
7export type Prefs = { pinned: string[]; hidden: string[] }
8export const NO_PREFS: Prefs = { pinned: [], hidden: [] }
9
10// Prefs as the store hands them back: only strings count, and a key is in one list at most.
11export function prefsOf(raw: unknown): Prefs {
12 const list = (v: unknown): string[] => (Array.isArray(v) ? [...new Set(v.filter((k): k is string => typeof k === 'string' && k !== ''))] : [])
13 const o = (typeof raw === 'object' && raw !== null ? raw : {}) as { pinned?: unknown; hidden?: unknown }
14 const pinned = list(o.pinned)
15 return { pinned, hidden: list(o.hidden).filter(k => !pinned.includes(k)) }
16}
17
18// What a person or Claude can name: every slot held, then the run's widgets under the keys the ranker gives them
19// (Claude's are claude/<id>, a bound one too, though the observer fills it; the plan mirror is hud/plan).
20export const keysOf = (r: HudRun | null, held: Record<string, HudHeld>): string[] =>
21 [...new Set([...Object.keys(held), ...(r?.widgets ?? []).map(w => `${isMirror(w) ? 'hud' : 'claude'}/${w.id}`)])]
22
23const SHOWN = 20
24export const keyList = (keys: string[]) => keys.slice(0, SHOWN).join(', ') + (keys.length > SHOWN ? ', …' : '')
25const find = (keys: string[], typed: string) => keys.find(k => k === typed) ?? keys.find(k => k.toLowerCase() === typed.toLowerCase())
26
27// /hud pin|unpin|hide|unhide <key>: what it did, in one sentence; or, for a key that isn't held, the keys that are.
28// A key that has gone (its run ended) can still be unpinned or unhidden, as the lists hold it.
29export function prefsCommand(prefs: Prefs, sub: 'pin' | 'unpin' | 'hide' | 'unhide', key: string, known: string[]): { prefs: Prefs; text: string } {
30 const typed = key.trim()
31 if (sub === 'pin' || sub === 'hide') {
32 const k = find(known, typed)
33 if (!k) return { prefs, text: !known.length ? `No slots to ${sub} yet.` : typed ? `No slot "${typed}". Name one of: ${keyList(known)}.` : `Name a slot to ${sub}: ${keyList(known)}.` }
34 const [mine, other] = sub === 'pin' ? [prefs.pinned, prefs.hidden] : [prefs.hidden, prefs.pinned]
35 if (mine.includes(k)) return { prefs, text: `${k} is already ${sub === 'pin' ? 'pinned' : 'hidden'}.` }
36 const was = other.includes(k)
37 return sub === 'pin'
38 ? { prefs: { pinned: [...prefs.pinned, k], hidden: prefs.hidden.filter(x => x !== k) }, text: `Pinned ${k}: it stays first${was ? ', and is no longer hidden' : ''}.` }
39 : { prefs: { pinned: prefs.pinned.filter(x => x !== k), hidden: [...prefs.hidden, k] }, text: `Hid ${k}${was ? ' (it was pinned)' : ''}: /hud unhide ${k} brings it back.` }
40 }
41 const [list, state] = sub === 'unpin' ? [prefs.pinned, 'pinned'] : [prefs.hidden, 'hidden']
42 if (!typed) return { prefs, text: list.length ? `Name one to ${sub}: ${keyList(list)}.` : `Nothing is ${state}.` }
43 const k = find(list, typed)
44 if (!k) return { prefs, text: `${typed} isn't ${state}.` }
45 return sub === 'unpin'
46 ? { prefs: { pinned: prefs.pinned.filter(x => x !== k), hidden: prefs.hidden }, text: `Unpinned ${k}.` }
47 : { prefs: { pinned: prefs.pinned, hidden: prefs.hidden.filter(x => x !== k) }, text: `${k} is shown again.` }
48}
49
50// "Pinned: a, b" and "Hidden: c" for /hud and the pane; nothing when there are none.
51export const prefsLines = (p: Prefs): string[] =>
52 [p.pinned.length > 0 && `Pinned: ${p.pinned.join(', ')}`, p.hidden.length > 0 && `Hidden: ${p.hidden.join(', ')}`].filter((l): l is string => typeof l === 'string')
53hooks/rank.ts 110 lines1import type { PluginState } from 'claude-code'
2
3import type { HudHeld, HudRun, HudTone, HudWidget } from '../types'
4import { isMirror, leadList, settle } from './lifecycle'
5import { MODES, type ModeName } from './modes'
6
7// Who decides what's shown: the person's pins, then Claude's focus, then the mode's slots, then
8// the score; a source's hint nudges a slot among equals. Pure: the hooks rank the board for the pane as it is, and the
9// strip only at boundaries, where its arrangement is kept (nextArrangement()).
10
11export type Placed = { key: string; slot: HudHeld; why: string }
12export type Prefs = { pinned: string[]; hidden: string[] }
13export type Arrangement = NonNullable<PluginState['hud']['arrangement']>
14
15export const PINNED = 'Pinned by you'
16export const FOCUS = "Claude's focus"
17
18const keyOf = (s: HudHeld) => `${s.from}/${s.id}`
19/** A widget's key as a slot: Claude's under `claude` (a bound one too, though the observer fills it), the observer's own
20 * (the plan mirror) under the HUD's name. */
21export const widgetKey = (w: HudWidget) => `${isMirror(w) ? 'hud' : 'claude'}/${w.id}`
22
23/** The run as it is drawn: without the widgets the person hid, so neither the lead checklist nor the strip's trend
24 * (Claude's first line) can bring one back, and a hidden plan mirror takes the progress it brought (settle()); Claude's
25 * own progress stays. */
26export const unhidden = (r: HudRun, hidden: readonly string[]): HudRun =>
27 hidden.length ? settle({ ...r, widgets: (r.widgets ?? []).filter(w => !hidden.includes(widgetKey(w))) }) : r
28
29/** The run's widgets seen as slots: Claude's from sender `claude` (key `claude/<id>`; a bound widget is Claude's, though
30 * the observer fills it), the observer's own (the plan mirror) from `hud` (key `hud/<id>`); pattern = type, `n` = the
31 * widget's `n`. A mirror kept but not drawn is no slot. */
32export function claudeSlots(r: HudRun | null): HudHeld[] {
33 if (!r) return []
34 const lead = leadList(r)
35 return (r.widgets ?? []).filter(w => typeof w.id === 'string' && (!isMirror(w) || w === lead)).map(w => ({
36 v: 1, id: w.id, pattern: w.type, title: w.title ?? w.id, value: w.value, unit: w.unit, min: w.min, max: w.max, delta: w.delta,
37 better: w.better, tone: w.tone, items: w.items, points: w.points, rows: w.rows, text: w.text,
38 from: isMirror(w) ? 'hud' : 'claude', at: w.at ?? 0, n: w.n,
39 }))
40}
41
42// Recency in main-loop calls, so the same calls rank the same however long they took; a slot from before `n` reads as just now.
43const age = (s: HudHeld, calls: number) => Math.max(0, calls - (s.n ?? calls))
44const SEVERITY: Record<HudTone, number> = { bad: 3, warn: 2, info: 1, accent: 1, good: 0.5 }
45
46/** score = severity(tone) × 0.5 ^ (calls since change / 12) + 0.5 × hint; severity bad 3, warn 2,
47 * info/accent/none 1, good 0.5. */
48export function score(s: HudHeld, calls: number): number {
49 return (s.tone ? SEVERITY[s.tone] : 1) * 0.5 ** (age(s, calls) / 12) + 0.5 * (s.hint ?? 0)
50}
51
52const changed = (s: HudHeld, calls: number) => {
53 const n = age(s, calls)
54 return `Changed ${n === 0 ? 'just now' : `${n} call${n === 1 ? '' : 's'} ago`} · ${s.tone ?? 'no tone'}`
55}
56
57/** Order: pinned (pin order), Claude's focus (its order), the mode's slots (the mode's order), then score; ties by
58 * declaration order, then key. Hidden keys never appear (muted senders' slots never reach it). */
59export function rank(slots: HudHeld[], o: { mode: ModeName; focus: string[]; prefs: Prefs; calls: number; order: (s: HudHeld) => number }): Placed[] {
60 const byKey = new Map(slots.filter(s => !o.prefs.hidden.includes(keyOf(s))).map(s => [keyOf(s), s]))
61 const out: Placed[] = []
62 const take = (keys: string[], why: string) => {
63 for (const key of keys) {
64 const slot = byKey.get(key)
65 if (slot) out.push({ key, slot, why }), byKey.delete(key)
66 }
67 }
68 take(o.prefs.pinned, PINNED)
69 take(o.focus, FOCUS)
70 take(MODES[o.mode]?.slots ?? [], `${o.mode[0]!.toUpperCase()}${o.mode.slice(1)} mode`)
71 const scored = [...byKey.values()].map(s => ({ s, score: score(s, o.calls) }))
72 .sort((a, b) => b.score - a.score || o.order(a.s) - o.order(b.s) || (keyOf(a.s) < keyOf(b.s) ? -1 : 1))
73 // the hint says so only where it lifted the slot above one that has none
74 // simplification: each scans the ones below it (quadratic); a board holds a few dozen slots at most
75 scored.forEach(({ s, score: mine }, i) => {
76 const lifted = (s.hint ?? 0) > 0 && scored.slice(i + 1).some(t => !t.s.hint && mine - 0.5 * s.hint! < t.score)
77 out.push({ key: keyOf(s), slot: s, why: lifted ? `Hinted by ${s.from}` : changed(s, o.calls) })
78 })
79 return out
80}
81
82/** The urgency place's slot (hooks/urgency.ts) says so, and comes after the pins only: the person outranks the rules. */
83export function urgentFirst(placed: Placed[], urgent: { key: string; says: string } | null | undefined): Placed[] {
84 const at = placed.findIndex(p => p.key === urgent?.key)
85 if (!urgent || at < 0 || placed[at]!.why === PINNED) return placed
86 const rest = placed.filter((_, i) => i !== at), pins = rest.filter(p => p.why === PINNED).length
87 return [...rest.slice(0, pins), { ...placed[at]!, why: `Urgent: ${urgent.says}` }, ...rest.slice(pins)]
88}
89
90// A slot that left the strip waits this many boundaries before it may come back, unless it changed since: so nothing
91// leaves and returns within 3 boundaries without a change of its own.
92export const WAIT = 3
93
94/**
95 * The strip at a boundary: `pick` chooses its keys (svg.ts stripSlots()) from the ranked board, less what waits. What
96 * the person pinned, or brought back (`back`, an unmute), never waits.
97 */
98export function nextArrangement(prev: Arrangement | undefined | null, placed: Placed[], pick: (candidates: Placed[]) => string[], now: number, back: string[] = []): Arrangement {
99 const b = (prev?.boundaries ?? 0) + 1, away = { ...prev?.away }
100 const of = (k: string) => placed.find(p => p.key === k)
101 const waits = (p: Placed) => {
102 const gone = away[p.key]
103 return gone !== undefined && p.why !== PINNED && !back.includes(p.key) && b - gone.b <= WAIT && gone.at === p.slot.at
104 }
105 const strip = pick(placed.filter(p => !waits(p)))
106 for (const k of prev?.strip ?? []) if (!strip.includes(k)) away[k] = { b, at: of(k)?.slot.at }
107 for (const [k, gone] of Object.entries(away)) if (strip.includes(k) || b - gone.b > WAIT) delete away[k]
108 return { strip, why: Object.fromEntries(strip.map(k => [k, of(k)!.why])), at: now, boundaries: b, away }
109}
110hooks/render.ts 283 lines1import type { HudHeld, HudLine, HudRun, HudWidget } from '../types'
2import { len, take } from './cells'
3import { isLive, life, span } from './lifecycle'
4import { widgetKey, type Placed } from './rank'
5import { GOAL } from './run'
6import { fit, fmt, plain, stepNow, type Picked } from './svg'
7
8const BARS = '▁▂▃▄▅▆▇█'
9export const spark = (pts: number[]) => {
10 const lo = Math.min(...pts), hi = Math.max(...pts)
11 return pts.map(p => BARS[hi === lo ? 3 : Math.round(((p - lo) / (hi - lo)) * 7)]).join('')
12}
13export const clock = (ms: number) => {
14 const s = Math.max(0, Math.round(ms / 1000))
15 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
16}
17// span() lives with the clocks, as svg.ts reads it too
18export { span }
19export const COLOR: Record<string, string> = {
20 now: 'warning', done: 'success', hit: 'success', ok: 'success',
21 failed: 'error', miss: 'error', err: 'error', warn: 'warning',
22 running: 'claude', retrying: 'warning', waiting: 'suggestion', 'waiting for you': 'suggestion', paused: 'inactive',
23}
24export const jobsText = (n: number) => `${n} ${n === 1 ? 'job' : 'jobs'} running`
25export const NBSP = '\u00a0' // spaces a desktop row keeps: never collapsed at a box's edge, never a line break
26export const BOLD = { bold: true }
27
28const BAR = 16
29const bar = (f: number, n: number) => `${'█'.repeat(Math.round(f * n))}${'░'.repeat(n - Math.round(f * n))}`
30const fraction = (w: HudWidget) => Math.min(1, Math.max(0, ((Number(w.value) || 0) - (w.min ?? 0)) / ((w.max ?? 100) - (w.min ?? 0) || 1)))
31/** A gauge, ring or meter's reading as the desktop headlines it: its value with its unit, and what it is of (`71.5 GB of 80`,
32 * `91.2%`), never its share rounded to a percentage that stands in for it. */
33const value = (w: HudWidget) => `${fmt(w.value)}${w.unit ? (w.unit === '%' ? '%' : ' ' + w.unit) : ''}`
34const reading = (w: HudWidget) => (w.max === undefined || (w.unit === '%' && w.max === 100) ? value(w) : `${value(w)} of ${fmt(w.max)}`)
35
36// Terminal: one or two text lines per widget.
37export const widgetText = (w: HudWidget): string => {
38 const items = w.items ?? []
39 switch (w.type) {
40 case 'line': return w.points?.length ? `${fmt(w.points.at(-1))} ${spark(w.points.slice(-40))}` : '…'
41 case 'gauge': case 'ring': case 'meter': return `${reading(w)} ${bar(fraction(w), BAR)}`
42 case 'heatmap': return `${w.rows?.length ?? 0}×${Math.max(0, ...(w.rows ?? []).map(r => r.length))} matrix`
43 case 'text': return w.text ?? ''
44 case 'stat': return `${fmt(w.value)}${w.unit ? ' ' + w.unit : ''}${w.delta !== undefined ? ` (${w.delta >= 0 ? '▲' : '▼'}${fmt(Math.abs(w.delta))})` : ''}`
45 default: return items.slice(0, 6).map(i => `${i.state === 'done' ? '✓ ' : i.state === 'now' ? '▸ ' : ''}${i.label}${i.value !== undefined ? ' ' + fmt(i.value) : ''}`).join(' · ')
46 }
47}
48
49// The band's last line. The newest line, except that the newest of Claude's own (no `by`, or 'claude') holds the place
50// until HELD observer lines have come after it: a run of edits must not drown what Claude said it meant. Goal changes
51// are the header's already, so they are skipped, and neither hold the place nor count.
52const HELD = 5
53export const lastLine = (log: HudLine[]): HudLine | undefined => {
54 const lines = log.filter(l => !l.text.startsWith(GOAL))
55 const mine = lines.findLastIndex(l => l.by !== 'observer')
56 return mine >= 0 && lines.length - 1 - mine < HELD ? lines[mine] : lines.at(-1)
57}
58
59// The header's facts, less what the graphic under it already draws; the step is cut to stepRoom, and left out
60// under 60 (in measure()'s units at 13px). Background jobs still at work show while the run is live. `pri`: which a
61// narrow terminal band keeps first (fitFacts()): the errors, the step, the progress, the clock, the jobs (what the strip
62// draws outranks the clock).
63export type Fact = { key: 'clock' | 'jobs' | 'progress' | 'step' | 'errors'; text: string; pri: number }
64export const factList = (r: HudRun, now: number, drawn: string[] = [], stepRoom = Infinity, jobs = 0): Fact[] => {
65 const step = stepNow(r)
66 return [
67 { key: 'clock' as const, text: `⏱ ${clock((r.endedAt ?? now) - r.startedAt)}`, pri: 3 },
68 ...(jobs && isLive(r) ? [{ key: 'jobs' as const, text: jobsText(jobs), pri: 4 }] : []),
69 ...(r.progress && !drawn.includes('progress') ? [{ key: 'progress' as const, text: `${r.progress.n}/${r.progress.total}${r.progress.label ? ' ' + plain(r.progress.label) : ''}`, pri: 2 }] : []),
70 ...(step && !drawn.includes(step.key) && stepRoom >= 60 ? [{ key: 'step' as const, text: `▸ ${fit(plain(step.name), stepRoom, 13)}`, pri: 1 }] : []),
71 ...(r.errors && !drawn.includes('errors') ? [{ key: 'errors' as const, text: `${r.errors} ${r.errors === 1 ? 'error' : 'errors'}`, pri: 0 }] : []),
72 ]
73}
74const FACT_GAP = ' · '
75export const factsOf = (r: HudRun, now: number, drawn: string[] = [], stepRoom = Infinity, jobs = 0) =>
76 factList(r, now, drawn, stepRoom, jobs).map(f => f.text).join(FACT_GAP)
77
78/** A text's width in terminal columns (a wide character or a pictograph counted as two: cells.ts). */
79export { len }
80
81/** The facts a terminal row has room for (`room` columns), as the desktop budgets its step: whole or not at all, never cut
82 * ("⏱ 7…" reads as 7 seconds), the ones that matter most kept first and shown in their own order. */
83export const fitFacts = (facts: Fact[], room: number, left?: Fact[]): string => {
84 const kept = new Set<Fact>()
85 let used = 0
86 for (const f of [...facts].sort((a, b) => a.pri - b.pri)) {
87 const w = len(f.text) + (kept.size ? len(FACT_GAP) : 0)
88 if (used + w <= room) (kept.add(f), (used += w))
89 }
90 left?.push(...facts.filter(f => !kept.has(f))) // what did not fit
91 return facts.filter(f => kept.has(f)).map(f => f.text).join(FACT_GAP)
92}
93
94// ---- what the terminal draws of the board ----------------------------------------------
95
96// The urgency place's mark takes the slot's tone as it stands now, as the desktop strip's does (c.tone(slot.tone)).
97const TONE_COLOR: Record<string, string> = { bad: 'error', warn: 'warning', good: 'success', info: 'suggestion', accent: 'claude' }
98
99/**
100 * The urgency place as one line: `⚠` and what it says, in the slot's tone as it stands now (bad red, warn the warning
101 * colour, good green, info blue, none or accent the accent), as the desktop's mark is. A held one whose slot is good again
102 * is green, never painted as the alarm it was.
103 */
104export const alarmLine = (u: { slot: HudHeld; says: string }) => ({ text: `⚠ ${plain(u.says)}`, color: TONE_COLOR[u.slot.tone ?? ''] ?? 'claude' })
105
106/** How long ago a widget or tile last changed, as the dashboard's aside says it: as of the run's last main-loop call, never now. Fresh, or with no clock, nothing. */
107export const agoOf = (clock: { at?: number; tick?: number } | undefined, r: HudRun) =>
108 clock && life(clock, r) !== 'fresh' && clock.at !== undefined && r.tickAt !== undefined ? `${span(r.tickAt - clock.at)} ago` : undefined
109
110/**
111 * One board entry as terminal text: its title (signed when another plugin sent it, as the dashboard signs it), its figure,
112 * and what the slot says it means; a widget of Claude's that has sat unchanged is `old`, and says how long (`ago`). `w` is
113 * the widget the figure is read from.
114 */
115export type Entry = { title: string; value: string; note?: string; old: boolean; ago?: string; w: HudWidget }
116export function entryOf(p: Placed, r: HudRun): Entry {
117 const w = (r.widgets ?? []).find(x => widgetKey(x) === p.key)
118 if (w) return { title: plain(w.title ?? w.id), value: plain(widgetText(w)), old: life(w, r) !== 'fresh', ago: agoOf(w, r), w }
119 const s = p.slot, as = { ...s, type: s.pattern } as HudWidget
120 return { title: `${plain(s.title)}${s.from === 'hud' ? '' : ` (from ${s.from})`}`, value: plain(widgetText(as)), ...(s.note ? { note: plain(s.note) } : {}), old: false, w: as }
121}
122
123/**
124 * A widget's figure for a band line, from the fullest to the barest, each keeping its number whole: the number comes
125 * before the spark or the bar, and the spark or bar is what shrinks (a line of points: the last value, then its spark; a
126 * gauge, ring or meter: its value, then what it is of, then its bar, as the desktop headlines it; a checklist: done of
127 * total, then the step in hand).
128 */
129export function figuresOf(w: HudWidget, wide = false): string[] {
130 const tiers = tiersOf(w)
131 // `wide`: the pane's, which opens with the whole of what widgetText() says (a line's forty points, a list's items)
132 return wide && !(w.type === 'line' && !w.points?.length) ? [...new Set([widgetText(w), ...tiers])] : tiers
133}
134function tiersOf(w: HudWidget): string[] {
135 switch (w.type) {
136 case 'line': {
137 const pts = w.points ?? []
138 if (!pts.length) return ['no points yet']
139 const last = fmt(pts.at(-1))
140 return [...new Set([16, 12, 8, 4].map(k => Math.min(k, pts.length)))].map(k => `${last} ${spark(pts.slice(-k))}`).concat(last)
141 }
142 case 'gauge': case 'ring': case 'meter': {
143 const f = fraction(w), full = reading(w)
144 return [BAR, 12, 8, 4].map(k => `${full} ${bar(f, k)}`).concat(full === value(w) ? [full] : [full, value(w)])
145 }
146 case 'checklist': {
147 const its = w.items ?? [], done = its.filter(i => i.state === 'done' || i.state === 'hit').length
148 const count = `${done} of ${its.length}`, cur = its.find(i => i.state === 'now') ?? its.find(i => i.state !== 'done' && i.state !== 'hit')
149 if (!cur) return [count]
150 // the step in hand, whole words only: shorter and shorter, down to the count alone
151 const words = plain(cur.label).split(/\s+/)
152 return words.map((_, k) => `${count} ▸ ${words.slice(0, words.length - k).join(' ')}`).concat(count)
153 }
154 case 'stat': {
155 const core = `${fmt(w.value)}${w.unit ? ' ' + w.unit : ''}`, full = widgetText(w)
156 return full === core ? [core] : [full, core]
157 }
158 default: return [widgetText(w)]
159 }
160}
161
162/** Text cut to `room` columns at a word, with "…". Never ends on a digit before it: a cut number reads as a smaller one. */
163export const cutText = (s: string, room: number): string => {
164 if (len(s) <= room) return s
165 let t = take(s, Math.max(0, room - 1))
166 if (s[t.length] !== ' ') { // not at a word's end already: back to the last one
167 const sp = t.lastIndexOf(' ')
168 if (sp >= 3) t = t.slice(0, sp)
169 }
170 return ellipsis(t)
171}
172// "…" after text that was cut, but never after a number: "18,2…" reads as 18, so the number goes whole (the word it ends).
173const ellipsis = (t: string) => {
174 const cut = t.replace(/\S*\d$/, '').replace(/[\s·,;:(\-–—]+$/, '')
175 return cut ? `${cut}…` : ''
176}
177
178/** A line of raw text (a log line: paths, hashes, URLs) in `cols` columns: first what says least is shortened (a path to its
179 * last parts, as the desktop's last line is, by fit()), then it is cut at a clause or a word, and never after a number. */
180export const fitText = (s: string, cols: number): string => {
181 const t = fit(s, cols * 7.8 + 0.01, 13, { mono: true }) // fit() measures a monospace column as 7.8px at 13px
182 return t.endsWith('…') ? ellipsis(t.slice(0, -1)) : t
183}
184
185/** One slot line of the band: two columns of indent, `title: figure`, and its why after them. */
186export type BandLine = { key: string; title: string; figure: string; why?: string; old: boolean }
187const INDENT = 2, WHY_MIN = 12, TITLE_MIN = 6
188
189/**
190 * The line in `cols` columns, or none. Never a cut figure: when it is too long the why gives way first (cut, down to two
191 * words at least, else dropped), then the figure's own spark or bar shrinks (its number stays), then the title is cut; a figure whose number
192 * even then has no room draws nowhere, and the line is dropped whole.
193 */
194export function fitLine(title: string, figures: string[], why: string | undefined, cols: number, o: { indent?: number; atomic?: boolean; keepTitle?: boolean } = {}): { title: string; figure: string; why?: string } | undefined {
195 const indent = o.indent ?? INDENT // `atomic`: the why is whole or gone ("· 3m ago" is no use cut)
196 const width = (t: string, f: string, y?: string) => indent + len(t) + 2 + len(f) + (y ? 2 + len(y) : 0)
197 const [fullest = ''] = figures
198 if (why && width(title, fullest, why) <= cols) return { title, figure: fullest, why }
199 const room = cols - width(title, fullest) - 2
200 const cutWhy = why && !o.atomic && room >= WHY_MIN ? cutText(why, room) : ''
201 if (/\s/.test(cutWhy)) return { title, figure: fullest, why: cutWhy } // one word left ("Changed…") says nothing: dropped
202 for (const f of figures) if (width(title, f) <= cols) return { title, figure: f }
203 const bare = figures.at(-1) ?? ''
204 if (o.keepTitle) return undefined // (the pane gives a figure that will not sit beside its whole title a line of its own)
205 const left = cols - indent - 2 - len(bare)
206 const t = left >= TITLE_MIN ? cutText(title, left) : ''
207 return t ? { title: t, figure: bare } : undefined
208}
209
210/**
211 * The first line of a pane block (its why is the line under it): `title: figure`, then what the slot says it means or how
212 * long a widget has sat, fitted to `cols` as a band line is: the figure whole (number first, its spark or bar shrinking),
213 * the tail cut at a word or dropped; the title is never cut. Nothing is left to the terminal to cut. None when even the
214 * barest figure has no room beside its whole title: the caller then draws the title on a line and the figure, the fullest
215 * that fits, on the next.
216 */
217export function blockLine(e: Entry, cols: number) {
218 const tail = e.note ?? (e.ago ? `· ${e.ago}` : undefined)
219 return fitLine(e.title, figuresOf(e.w, true), tail, cols, { indent: 0, atomic: e.note === undefined, keepTitle: true })
220}
221
222/**
223 * The lines the terminal's band draws under its header: exactly the items the desktop strip chose (`picked`, from
224 * svg.ts, in its order), each as it stands now. A slot is the board's entry (its figure, why and tone are the live ones);
225 * a tile, the trend and the call count are the run's own. Progress, phases, errors and the urgency place are the header's
226 * and the alarm line's. A slot the board no longer holds (hidden, muted) draws nowhere; an item that fits no line is dropped.
227 */
228export function bandLines(picked: Picked[], board: Placed[], r: HudRun, cols: number): BandLine[] {
229 return picked.flatMap(it => {
230 const p = it.slot ? board.find(x => x.key === it.slot) : undefined
231 if (it.slot && !p) return []
232 const e = p ? entryOf(p, r) : it.own && { title: plain(it.own.title), old: life(it.own.clock ?? {}, r) !== 'fresh', w: it.own.w }
233 if (!e) return []
234 const got = fitLine(e.title, figuresOf(e.w), p?.why, cols)
235 return got ? [{ key: it.key, ...got, old: e.old }] : []
236 })
237}
238
239// What the strip chose of the run's own and the header could not say, as a line of its own (headerOf()).
240const OWN: Record<'progress' | 'errors' | 'step', (r: HudRun) => { key: string; title: string; figures: string[] } | undefined> = {
241 progress: r => r.progress && { key: 'progress', title: 'Progress', figures: [`${r.progress.n} of ${r.progress.total}${r.progress.label ? ' ' + plain(r.progress.label) : ''}`, `${r.progress.n} of ${r.progress.total}`] },
242 errors: r => ({ key: 'errors', title: 'Errors', figures: [`${r.errors} of ${r.calls} calls`, String(r.errors)] }),
243 step: r => {
244 const at = r.phases.findIndex(p => p.state === 'now')
245 if (at < 0) return undefined
246 const count = `${at + 1} of ${r.phases.length}`, words = plain(r.phases[at]!.name).split(/\s+/)
247 return { key: 'phases', title: 'Phase', figures: words.map((_, k) => `${count} ▸ ${words.slice(0, words.length - k).join(' ')}`).concat(count) }
248 },
249}
250
251/** The band's header row: what it says in `cols` columns, every text whole to the column it is given. */
252export type Header = { title: string; goal?: string; facts: string; bar: number; own: BandLine[] }
253
254/**
255 * The header row, budgeted as the desktop's is: `dot` columns for the mark and status, `buttons` for the two buttons, the
256 * title keeping a floor, and then in the order they give way: the progress bar first (what is left once the title and the
257 * goal are whole, down to 10 columns, then none: progress is a fact too), the title's goal (cut, then dropped), the title
258 * (cut), and last the facts (the errors, the step, the progress, the clock, the jobs, each whole or dropped, never cut). Nothing is left to the terminal to cut, so no number is. What the strip chose
259 * (`picked`: progress, errors, phases) and the header has no room for becomes a line of its own, never dropped while the
260 * desktop strip shows it. `drawn`: the keys of the lines the band draws, whose step or progress is not also a fact. With
261 * the dashboard `open`, the title alone.
262 */
263export function headerOf(o: { r: HudRun; now: number; jobs: number; cols: number; dot: number; buttons: number; hasBar: boolean; picked: string[]; drawn: string[]; open: boolean }): Header {
264 const title = plain(o.r.title), goal = o.r.goal ? plain(o.r.goal) : '', room = o.cols - o.dot - o.buttons
265 if (o.open) return { title: cutText(title, room), facts: '', bar: 0, own: [] }
266 const floor = Math.min(len(title), Math.max(14, Math.floor(room * 0.4))), left: Fact[] = []
267 const facts = fitFacts(factList(o.r, o.now, o.drawn, Infinity, o.jobs), room - floor - 2, left)
268 const factW = facts ? len(facts) + 2 : 0, nominal = Math.max(10, Math.min(24, o.cols - 60))
269 // the bar is what is left once the title and the goal are whole, 10 columns at least, else none
270 const spare = room - factW - len(title) - (goal ? 4 + len(goal) : 0)
271 const bar = !o.hasBar ? 0 : spare >= nominal + 2 ? nominal : spare >= 12 ? spare - 2 : 0
272 const width = room - factW - (bar ? bar + 2 : 0), shown = len(title) > width ? cutText(title, width) : title
273 const rest = width - len(title) - 4 // the goal follows the title as " → goal"
274 const own = ([['progress', 'progress'], ['errors', 'errors'], ['step', 'phases']] as const).flatMap(([fact, key]) => {
275 const missing = fact === 'progress' ? !bar && left.some(f => f.key === fact) : left.some(f => f.key === fact)
276 const it = o.picked.includes(key) && missing ? OWN[fact](o.r) : undefined
277 const line = it && fitLine(it.title, it.figures, undefined, o.cols)
278 return it && line ? [{ key: it.key, ...line, old: false }] : []
279 })
280 const cutGoal = shown === title && goal && rest >= 10 ? cutText(goal, rest) : ''
281 return { title: shown, ...(cutGoal ? { goal: cutGoal } : {}), facts, bar, own }
282}
283hooks/run.ts 235 lines1import type { HudItem, HudLine, HudRun, HudState, HudTone, HudWidget } from '../types'
2import { rebind } from './bind'
3import { boundary, isLive, life, PAUSED, settle, stamp, WAITING } from './lifecycle'
4import { laid, RECIPE_NAMES, RECIPES, recipeOf, widgetsOf } from './recipes'
5import { STATES, TONES, TYPES } from './sources/contract'
6
7export { isLive }
8
9export type WidgetInput = Omit<Partial<HudWidget>, 'bind'> & { id: string; push?: number; bind?: unknown }
10export type Input = {
11 start?: string; goal?: string; rebuild?: boolean; recipe?: string; title?: string; accent?: string; strip?: string[]; set?: Record<string, string | number>; log?: string; level?: HudLine['level']
12 status?: string; progress?: string; progress_label?: string
13 phase?: { name: string; state: HudState }; cell?: { name: string; state: HudState }
14 point?: number; count?: string; count_by?: number; fun?: string[]; next?: number
15 /** Claude's intent, applied by register.tsx's intend() to $.state (they are not part of the run). */
16 mode?: string; focus?: string[]
17 widget?: WidgetInput; widgets?: WidgetInput[]; remove?: string[]
18 done?: string; fail?: string
19}
20
21// How a goal change reads in the log; the band's last line skips it, as its header shows the goal already.
22export const GOAL = 'Goal: '
23
24export const fresh = (title: string, now: number): HudRun => ({
25 title, startedAt: now, status: 'running', tiles: {}, phases: [], cells: [],
26 points: [], counts: {}, fun: [], log: [], widgets: [], calls: 0, errors: 0,
27})
28
29// What a rebuild clears: the layout. The title, goal, clock, log and counts stay.
30export const cleared = (): Partial<HudRun> => ({
31 tiles: {}, phases: [], cells: [], points: [], counts: {}, fun: [], widgets: [],
32 progress: undefined, progressBy: undefined, strip: undefined, nextAt: undefined, tileClock: {}, named: undefined,
33})
34
35export const upsert = (list: HudRun['phases'], name: string, state: HudState) =>
36 list.some(x => x.name === name)
37 ? list.map(x => (x.name === name ? { name, state } : x))
38 : [...list, { name, state }]
39
40export const mergeWidget = (list: HudWidget[], { push, ...w }: Omit<WidgetInput, 'bind'>): HudWidget[] => {
41 const old = list.find(x => x.id === w.id)
42 const merged = { type: 'stat' as const, ...old, ...w }
43 if (push !== undefined) merged.points = [...(merged.points ?? []), push].slice(-200)
44 return old ? list.map(x => (x.id === w.id ? merged : x)) : [...list, merged]
45}
46
47// Tool input is the model's: a field the schema did not declare yet arrives as a JSON string.
48const parse = (v: unknown): unknown => {
49 if (typeof v !== 'string') return v
50 try { return JSON.parse(v) } catch { return undefined }
51}
52export const asList = (v: unknown): unknown[] => {
53 v = parse(v)
54 return v === undefined || v === null ? [] : Array.isArray(v) ? v : [v]
55}
56export const asObject = (v: unknown): Record<string, unknown> | undefined => {
57 v = parse(v)
58 return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined
59}
60const isString = (x: unknown): x is string => typeof x === 'string'
61// A { name, state } pair; anything else is junk.
62const asNamed = (v: unknown): { name: string; state: HudState } | undefined => {
63 const o = asObject(v)
64 return o && isString(o.name) && o.name && STATES.includes(o.state as HudState) ? { name: cap(o.name), state: o.state as HudState } : undefined
65}
66
67// ---- The guard every call goes through (apply()). The model's input is any JSON, whatever the schema says, and what the
68// run keeps is drawn on every redraw, where a field of another type throws: so each field is made what it is drawn as, or
69// dropped (a number where text goes is written out). Claude's text is kept to TEXT characters: more is never drawn whole,
70// and what fit() cuts it cuts from that. A colour goes into the image's attributes, so it is a hex or a tone name; progress
71// is n of its total, 0 ≤ n ≤ total. A refusal is a line in the tool's answer.
72const TEXT = 2000
73const cap = (s: string) => s.slice(0, TEXT)
74const text = (v: unknown) => (isString(v) ? cap(v) : typeof v === 'number' && Number.isFinite(v) ? String(v) : undefined)
75const number = (v: unknown) => { const n = parse(v); return typeof n === 'number' && Number.isFinite(n) ? n : undefined }
76const listOf = (v: unknown) => { const a = parse(v); return Array.isArray(a) ? a : undefined }
77const oneOf = <T>(list: readonly T[], v: unknown) => (list.includes(v as T) ? (v as T) : undefined)
78const colour = (v: unknown) => (isString(v) && (/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(v) || TONES.includes(v as HudTone)) ? v : undefined)
79const defined = <T extends object>(o: T) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T
80const quoted = (v: unknown) => JSON.stringify(String(v).slice(0, 20))
81const NO_COLOUR = 'is no colour; send a hex like #d97757, or good, bad, warn, info or accent'
82const LEVELS: readonly HudLine['level'][] = ['info', 'ok', 'warn', 'err']
83
84function widgetOf(raw: unknown, said: string[]): WidgetInput | undefined {
85 const w = asObject(raw)
86 if (!w || !isString(w.id)) return undefined
87 let refused: unknown // the first item colour that is no colour, named once for the widget
88 const items = listOf(w.items)?.flatMap(x => {
89 const it = asObject(x), label = text(it?.label)
90 if (!it || label === undefined) return []
91 if (it.color !== undefined && colour(it.color) === undefined) refused ??= it.color
92 return [defined<HudItem>({ label, value: number(it.value), state: oneOf(STATES, it.state), color: colour(it.color), note: text(it.note) })]
93 })
94 if (refused !== undefined) said.push(`color on ${w.id.length > 60 ? w.id.slice(0, 59) + '…' : w.id}: ${quoted(refused)} ${NO_COLOUR}`)
95 return defined<WidgetInput>({
96 id: cap(w.id), type: oneOf(TYPES, w.type), title: text(w.title), span: number(w.span),
97 value: typeof w.value === 'number' ? number(w.value) : text(w.value), min: number(w.min), max: number(w.max), unit: text(w.unit),
98 delta: number(w.delta), better: oneOf(['up', 'down'] as const, w.better), tone: oneOf(TONES, w.tone), items,
99 points: listOf(w.points)?.flatMap(n => number(n) ?? []), push: number(w.push),
100 rows: listOf(w.rows)?.flatMap(r => { const row = listOf(r); return row ? [row.flatMap(n => number(n) ?? [])] : [] }),
101 text: text(w.text), ...('bind' in w ? { bind: w.bind } : {}),
102 })
103}
104
105/** The input as the run can keep it: each field its type or gone, text kept to 2,000 characters; `said` takes a line for
106 * each colour and progress refused. A start stays a start (`Task` when its title is no text). */
107export function clean(i: Input, said: string[] = []): Input {
108 const o = i as Record<string, unknown>
109 if (o.accent !== undefined && o.accent !== '' && colour(o.accent) === undefined) said.push(`accent: ${quoted(o.accent)} ${NO_COLOUR}`)
110 let progress: string | undefined
111 if (o.progress !== undefined && o.progress !== '') {
112 const [n = NaN, total = NaN] = String(o.progress).split('/').map(Number)
113 if (Number.isFinite(n) && Number.isFinite(total) && total > 0 && n >= 0 && n <= total) progress = String(o.progress)
114 else said.push(`progress: ${quoted(o.progress)} is not n/total with 0 ≤ n ≤ total`)
115 }
116 return {
117 ...i, start: o.start === undefined ? undefined : text(o.start) ?? 'Task', goal: text(o.goal), title: text(o.title),
118 accent: colour(o.accent), log: text(o.log), level: oneOf(LEVELS, o.level), status: text(o.status), progress,
119 progress_label: text(o.progress_label), point: number(o.point), count: text(o.count), count_by: number(o.count_by),
120 next: number(o.next), done: text(o.done), fail: text(o.fail),
121 widget: undefined, widgets: [...asList(o.widget), ...asList(o.widgets)].flatMap(w => widgetOf(w, said) ?? []),
122 }
123}
124
125// A start while the live run started on its own, since the person's last message (`since`), adopts it: Claude names
126// the work this turn began, and its log, counts, clock and widgets stay. A start on a run Claude began, or on one an
127// earlier message began, starts fresh, for a new task. A title adopts whatever its age: Claude
128// claims the run it is looking at.
129const ownsTurn = (prev: HudRun | null, since: number) => isLive(prev) && prev.provisional === true && prev.startedAt >= since
130export const adopts = (prev: HudRun | null, i: Input, since = -Infinity) => i.start !== undefined && ownsTurn(prev, since)
131
132// `said` takes the lines for the tool's answer: what a recipe left to bind or why it could not be laid (`recipe: unknown "x"
133// (…)`), and each binding that could not stand (`bind on <id>: <why>`). `calls`: the session's main-loop tool calls,
134// stamped on each widget sent as `n` (the ranker's recency).
135export function apply(prev: HudRun | null, sent: Input, now: number, since = -Infinity, said: string[] = [], calls?: number): HudRun {
136 const i = clean(sent, said) // every field its type, or gone
137 const adopt = adopts(prev, i, since)
138 const r = (i.start !== undefined && !adopt) || prev === null ? fresh(i.start ?? 'Task', now) : { ...prev, widgets: (prev.widgets ?? []).filter(w => typeof w.id === 'string') }
139 if (adopt && i.start) r.title = i.start
140 const say = (text: string, level: HudLine['level'] = 'info') => {
141 r.log = [...r.log, { at: now, text: cap(text), level }].slice(-200)
142 }
143 r.updatedAt = now
144 r.sinceUpdate = 0
145 r.nudged = undefined
146 // simplification: rebuild is a boolean, but arrives as "true" for a schema not declared yet (asList's case)
147 const rebuild = String(i.rebuild) === 'true' && i.start === undefined
148 if (rebuild) {
149 Object.assign(r, cleared())
150 // simplification: rebuilding a finished run means the work goes on
151 if (!isLive(r)) Object.assign(r, { status: 'running', summary: undefined, endedAt: undefined })
152 }
153 // A recipe (hooks/recipes.ts) begins a start's or a rebuild's layout and nothing else's: its strip goes in here, its widgets
154 // below, ahead of Claude's own, so what Claude sends in the same call overrides them and what it sends later does too.
155 const recipe = i.recipe === undefined ? undefined : recipeOf(i.recipe)
156 const begins = i.start !== undefined || rebuild
157 if (i.recipe !== undefined && !begins) said.push('recipe: send it with start or rebuild')
158 else if (i.recipe !== undefined && !recipe) said.push(`recipe: unknown "${String(i.recipe).slice(0, 20)}" (recipes: ${RECIPE_NAMES.join(', ')})`)
159 const laying = begins ? recipe : undefined
160 const told = said.length // where the recipe's line goes, written once the call is done (it says what the layout is then)
161 const strip = laying && RECIPES[laying].strip
162 if (strip) r.strip = [...strip]
163 if (i.title) r.title = i.title
164 // named by Claude, it is no longer provisional (and no longer dim)
165 if (adopt || i.title) r.provisional = undefined
166 if (i.goal !== undefined && i.goal !== r.goal) {
167 r.goal = i.goal || undefined
168 if (i.goal) say(GOAL + i.goal)
169 } else if (rebuild) say('Dashboard rebuilt')
170 if (i.accent) r.accent = i.accent
171 if (i.strip) r.strip = asList(i.strip).filter(isString).map(cap)
172 const set = asObject(i.set)
173 if (set) {
174 // each tile Claude sends is stamped with its clock, as a widget is
175 const tiles = Object.entries(set).flatMap(([k, v]) => (isString(v) || (typeof v === 'number' && Number.isFinite(v)) ? [[cap(k), isString(v) ? cap(v) : v] as [string, string | number]] : []))
176 r.tiles = { ...r.tiles, ...Object.fromEntries(tiles) }
177 r.tileClock = { ...r.tileClock, ...Object.fromEntries(tiles.map(([k]) => [k, stamp(r, now)])) }
178 }
179 // a hud call is Claude at work: a run waiting for the person or paused runs again (a status Claude sends wins)
180 if (r.status === WAITING || r.status === PAUSED) r.status = 'running'
181 if (i.status) r.status = i.status
182 if (i.progress) {
183 const [n = NaN, total = 0] = i.progress.split('/').map(Number)
184 if (Number.isFinite(n) && total > 0) Object.assign(r, { progress: { n, total, label: i.progress_label }, progressBy: undefined }) // Claude's, as the mirror's no longer is
185 }
186 const phase = asNamed(i.phase), cell = asNamed(i.cell)
187 if (phase) {
188 // simplification: one 'now' at a time, matching the old hud.py
189 const phases = phase.state === 'now'
190 ? r.phases.map(p => (p.state === 'now' ? { ...p, state: 'done' as const } : p))
191 : r.phases
192 r.phases = upsert(phases, phase.name, phase.state)
193 }
194 if (cell) r.cells = upsert(r.cells, cell.name, cell.state)
195 if (i.point !== undefined) r.points = [...r.points, i.point].slice(-120)
196 if (i.count) r.counts = { ...r.counts, [i.count]: (r.counts[i.count] ?? 0) + (i.count_by ?? 1) }
197 if (i.fun) {
198 // simplification: a real [] still clears the strip; anything else with no line in it (junk) changes nothing
199 const fun = asList(i.fun).filter(isString).map(cap), held = parse(i.fun)
200 if (fun.length || (Array.isArray(held) && !held.length)) r.fun = fun.slice(0, 3)
201 }
202 if (i.next !== undefined) r.nextAt = now + i.next * 1000
203 // what Claude sends is fresh again: stamped with its clock, no longer stale, and free to be named again. A binding
204 // sent with it is checked (hooks/bind.ts); one that can't stand leaves the widget unbound, and the answer says why.
205 for (const w of [...(laying ? widgetsOf(RECIPES[laying]) : []), ...asList(i.widget), ...asList(i.widgets)]) {
206 if (typeof w !== 'object' || w === null || typeof (w as WidgetInput).id !== 'string') continue
207 const { bind, ...sent } = w as WidgetInput
208 const was = r.widgets.find(x => x.id === sent.id)
209 r.widgets = mergeWidget(r.widgets, { ...sent, ...stamp(r, now), staleAt: undefined, n: calls }).map(x => {
210 if (x.id !== sent.id) return x
211 const b = rebind(x, was, 'bind' in w ? { ...sent, bind: asObject(bind) ?? bind } : sent)
212 if (b.why) said.push(`bind on ${sent.id.length > 60 ? sent.id.slice(0, 59) + '…' : sent.id}: ${b.why}`)
213 return b.w
214 })
215 // named again only once it is fresh: a bound widget still stale (no reading, no value) was named already
216 const kept = r.widgets.find(x => x.id === sent.id)
217 if (!kept || life(kept, r) !== 'stale') r.named = r.named?.filter(id => id !== sent.id)
218 }
219 const gone = asList(i.remove).filter(isString)
220 if (gone.length) r.widgets = r.widgets.filter(w => !gone.includes(w.id))
221 if (laying) said.splice(told, 0, laid(laying, r.widgets))
222 if (phase || rebuild) Object.assign(r, boundary(r, now))
223 if (i.log) say(i.log, i.level)
224 // Finishing closes the step that was running, so a finished run shows no current phase.
225 const finish = (status: 'done' | 'failed', summary: string, level: HudLine['level']) => {
226 Object.assign(r, { status, summary, endedAt: now, nextAt: undefined })
227 r.phases = r.phases.map(p => (p.state === 'now' ? { ...p, state: status } : p))
228 say(summary, level)
229 }
230 // One outcome per call: fail wins over a done sent with it, so a failed run never shows its step done.
231 if (i.fail !== undefined) finish('failed', i.fail, 'err')
232 else if (i.done !== undefined) finish('done', i.done, 'ok')
233 return settle(r)
234}
235