SLOPSHOPPER

HUD for Claude Code Desktop

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…

newpanebandguardcommandprompt
v2.0.0MITupdated 2026-10-14kyledarling-io/claude-code-desktop-hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hud
│ ┃ hud ✕ › fix the failing auth test and add an audit log call │ ┃ fix the failing auth test and add an audit │ ┃ log call ⏺ Read(src/auth.ts) │ ┃ ● waiting for you · 0:00 ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ╭─────────────────────────────────────────╮ ⎿ Added 2 lines, removed 1 line │ ┃ │ tool calls │ ⏺ Bash(bun test) │ ┃ │ 1 │ ⎿ 3 pass, 1 fail │ ┃ ╰─────────────────────────────────────────╯ │ ┃ Tests: 1 failed bun test: 3 passed, 1 faile ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Build mode │ ┃ Failing runs: bun test 1 bun test failed on ✻ Worked for 42s · done 4:20 PM │ ┃ Build mode │ ┃ Files edited: 3 files 3 edits; latest cache › /hud │ ┃ Build mode ⎿ hud: fix the failing auth test and add an audit log call │ ┃ Context: 49% ████████░░░░░░░░ 97k of 200k t ⎿ hud: Waiting for you · ⏱ 0:00 │ ┃ Changed just now · no tone ⎿ hud: Tests — Build mode │ ┃ Cost: 0.42 USD this session, as /cost count ⎿ hud: Files edited — Build mode │ ┃ Changed just now · no tone ⎿ hud: Mode: Build │ ┃ ⎿ hud: The strip is back above the prompt. Expand opens the dashbo │ ┃ │ ┃ Showing │ ┃ Mode: Build │ ┃ Switch with /hud mode <name>. /hud pin │ ┃ <slot> keeps one first, /hud hide <slot> │ ┃ leaves it out. ● Waiting for you fix the failing auth test and add an audit log call ⏱ 0:00[ Expand ⤢ ]✕ Tests: 1 failed Build mode Files edited: 3 files Build mode ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● Waiting for you fix the failing auth test and add an audit log call ⏱ 0:00[ Expand ⤢ ]✕ Tests: 1 failed Build mode Files edited: 3 files Build mode
Pane · hud
fix the failing auth test and add an audit log call ● waiting for you · 0:00 ╭─────────────────────────────────────────────────────────╮ │ tool calls │ │ 1 │ ╰─────────────────────────────────────────────────────────╯ Tests: 1 failed bun test: 3 passed, 1 failed Build mode Failing runs: bun test 1 bun test failed once in a row Build mode Files edited: 3 files 3 edits; latest cache.ts Build mode Context: 49% ████████░░░░░░░░ 97k of 200k tokens Changed just now · no tone Cost: 0.42 USD this session, as /cost counts it Changed just now · no tone Showing Mode: Build Switch with /hud mode <name>. /hud pin <slot> keeps one first, /hud hide <slot> leaves it out. Sources Test results · HUD Reads the output of shell commands that run tests, for their pass and fail counts. Keeps the counts only. Tests Tool signals · HUD Reads each tool call as it finishes: the names of shell commands, edited files and fetched sites, and whether the call failed. Keeps counts, never file contents. Failing runs, Files edited, Fetched Context and cost · HUD Reads the figures the app reports after each turn: the context window's fill, the session's cost and the rate-limit windows. Nothing else. Context, Cost, Usage limit Agents · HUD Reads when Claude starts a subagent and when it finishes: its description and whether it failed. Nothing else. Agents, Agents running Hide one with /hud mute <name> and bring it back with /hud unmute <name>. /hud sources lists the names.
README

HUD for Claude Code

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.

Install

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 run, start to finish

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.

What it does on its own

  • Starts itself. When a turn becomes a long task (eight tool calls, or a minute) and no run is going, a run starts, titled from your message, dim until Claude names it. Only where someone is watching, and never from a subagent.
  • Reads the work. Tests failing, files edited, commands failing in a row, sites fetched, subagents at work, context and cost. All from Claude's tool calls, none from Claude's words.
  • Writes the log. Edits, failed commands, changes in the test count, fetches and searches land in the log as dim lines beside Claude's own.
  • Raises what's urgent. Tests that were passing and now fail, the same command failing three times, the context window or a rate limit past 85%. The HUD's own rules raise it, so no plugin and no prompt can.
  • Holds still. The strip changes only at a boundary (a turn's end, a phase, a rebuild), so it doesn't flicker. Figures that stop changing dim with their age, and leave if they stay stale.
  • Fills widgets by itself. Claude can bind a figure to a command, a file or a site and leave it. Each matching call is read for one number. With nothing behind it, a widget says "no reading", never an old number.

What Claude can show

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">

Commands

CommandDoes
/hudBrings 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

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">

Sources

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.

What it can access

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.

Development

See DEVELOPING.md. Run the tests with claude plugin test ..

Created by Kyle Darling. Built with Claude Code. MIT License.

Source 26 files
hooks/register.tsx 1090 lines
1import { 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}
1090
hooks/autolog.ts 88 lines
1import 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}
88
hooks/bind.ts 164 lines
1import 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}
164
hooks/board.ts 29 lines
1import 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)
29
hooks/lifecycle.ts 124 lines
1import 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
124
hooks/modes.ts 44 lines
1import 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))]
44
hooks/notes.ts 106 lines
1import 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.'
106
hooks/plan.ts 89 lines
1import 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}
89
hooks/prefs.ts 53 lines
1import 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')
53
hooks/rank.ts 110 lines
1import 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}
110
hooks/render.ts 283 lines
1import 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}
283
hooks/run.ts 235 lines
1import 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