Throwaway probe for the HUD: turn, subagent, permission, compaction and task-list events; delete after reading

A live progress strip above the prompt, and a dashboard beside the conversation, for the long tasks Claude Code works on. It runs in the Claude Desktop app's Code tab and in the terminal.
<img src="docs/img/band-prompt.png" width="780" alt="The strip above the prompt during a rollout: an urgent line saying tests that were passing now fail, 7 of 12 hosts, requests per second down 830, p95 latency 231 ms, the Rollout phase, and the last log line about a failed health check">
While Claude builds, migrates, refactors or retries, the strip shows the run's title and goal, its progress, the figures that matter and the last thing that happened. Expand opens the full dashboard. You don't have to ask for it: the HUD reads Claude's tool calls for what it can see on its own, starts a run when a message turns into a long task, and keeps the board honest while Claude works. Claude adds only what it alone knows: the title, the goal, the phases, and done or failed.
In Claude Code:
/plugin install hud --marketplace kyledarling-io/claude-code-desktop-hud
In the Claude Desktop app: Customize → Plugins → Add → Add marketplace → Add from a repository, paste https://github.com/kyledarling-io/claude-code-desktop-hud, sync, then install hud from the marketplace. Start a Code session and the strip appears when Claude starts a long task, or say "show me a HUD while you do this."
Needs Claude Code 2.1.295 or later. VS Code and the mobile app don't draw the strip yet.
A rollout to twelve hosts where one fails its health check.
| <img src="docs/img/strip-1-start.png" width="400" alt="The strip at the start: 0 of 12 hosts, requests per second, p95 latency, the Build phase of four"> | Start. Claude names the run, its goal and its phases. Progress, figures and the phase rail take the strip. |
| <img src="docs/img/strip-2-canary.png" width="400" alt="The strip mid-run: 4 of 12 hosts, requests per second up 950, p95 latency up 7 ms, the Canary phase with one done"> | Mid-run. Figures carry a change and a sparkline, green or red once Claude says which way is good. |
| <img src="docs/img/strip-3-failure.png" width="400" alt="The strip after a failure: an Urgent line, tests 2 failed were passing, 7 of 12 hosts, requests down, latency up, the Rollout phase"> | Something breaks. Tests that were passing now fail. The HUD saw it in the test runner's own output and put it first, ahead of everything Claude sent. |
<img src="docs/img/dashboard-failure.png" width="360" alt="The dashboard pane for the same rollout: progress, the phase rail, the host grid with one cross, counts, the tests figure marked urgent, figures with a line under each saying why it is shown, a checklist, the HUD's own context and cost figures, the log, the Showing block with the mode, and the Sources list">
The dashboard shows everything on the board. Under each figure is a line saying why it is there: Urgent, Claude's focus, Build mode, Changed 2 calls ago · warn, or the name of the plugin that sent it. At the bottom, Showing names your mode, pins and hides, and Sources lists every plugin feeding the board and what each one reads.
Progress, a phase rail, figures with deltas and sparklines, a grid of hosts or files, a trend line, counts, a countdown, a log, and gauge, ring, meter, line, bars, columns, donut, heatmap, ranked list, checklist, table and callout. A training run with most of them in use:
<img src="docs/img/strip-4-training.png" width="780" alt="The strip for a training run: 4 of 10 epochs, a validation accuracy gauge at 91.2%, the Train phase of four, and the last log line">
<img src="docs/img/window-training.png" width="900" alt="The Claude Desktop window with the conversation blurred, the one-line strip above the prompt, and the dashboard pane on the right showing the training run: progress, phases, a gauge, a loss line, meters and figures">
| Command | Does | ||||
|---|---|---|---|---|---|
/hud | Brings back a hidden strip and prints the run, with why each figure is shown | ||||
| `/hud mode <build\ | debug\ | review\ | research\ | plan>` | Sets which figures come first. Yours outranks Claude's. |
/hud pin <slot>, /hud hide <slot> | Keeps a figure first, or leaves it out, in this session and the next | ||||
/hud sources, /hud mute <name> | Lists the plugins feeding the board, or silences one |
In the terminal the band above the prompt is text: the same figures, in the same order, each with why it is there.
<img src="docs/img/terminal-band.png" width="600" alt="The terminal band above the prompt: the run's title, progress, the figures as text with why each is shown, and the last log line">
Any plugin can send the HUD figures of its own through $.hud: a deploy's state, a queue's depth, the last build. The HUD checks what it gets, ranks it with everything else and shows the sender's name beside it. Two sources ship here, each its own install: hud-git (branch, files changed, ahead and behind) and hud-ci (your branch's GitHub Actions run). SOURCES.md is the guide to writing one, and examples/source-template is a working source with its test.
The HUD reads no files, makes no network requests, starts no processes and calls no model. It reads Claude's tool calls for names and counts (a command's name, a file's path, the pass and fail counts a test runner prints), never file contents, and nothing else from a command's output. Claude gets one cached section of its system prompt and a one-line note beside your messages during a run. Nothing another plugin sends reaches Claude. claude plugin validate . lists every hook it handles and every call it makes.
See DEVELOPING.md. Run the tests with claude plugin test ..
Created by Kyle Darling. Built with Claude Code. MIT License.
hooks/register.ts 101 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// simplification: a throwaway probe for the HUD's platform questions (steps: dev/probes/README.md).
4// Each hook logs what it saw and passes the event on untouched. `/probe-platform` prints the log; the `budget` tool
5// returns it to Claude. The log is module memory: a reload empties it.
6const lines: string[] = []
7
8async function log($: EngineInterface, ev: string, data: unknown) {
9 try {
10 lines.push(`${new Date(await $.clock.now()).toISOString()} ${ev} ${JSON.stringify(data).slice(0, 600)}`)
11 } catch (err) {
12 lines.push(`log failed: ${String(err)}`)
13 }
14}
15
16// A description of 4,352 characters with a marker every 256: the last marker Claude reads is the budget.
17const MARKS = Array.from({ length: 17 }, (_, i) => {
18 const tag = `[M${(i + 1) * 256}]`
19 return tag.padStart(256, i === 0 ? 'Probe tool: returns the probe log. Budget markers follow. ' + '.'.repeat(200) : '.')
20}).join('')
21
22async function start($: EngineInterface) {
23 const v = await $.session.version()
24 const names = (await $.tool.list()).map(t => t.name)
25 await log($, 'session.start', { version: v, surfaces: await $.session.surfaces(), taskTools: names.filter(n => /^(TodoWrite|Task)/.test(n)), tools: names.length })
26 await $.tool.register({ name: 'budget', description: MARKS, inputSchema: { type: 'object', properties: {} }, isDeferred: false })
27 await $.command.register({ name: 'probe-platform', description: 'Show the probe log tail' })
28}
29
30export const register: Register = on => {
31 on('session.start', async ($, e, next) => {
32 const r = await next(e)
33 await start($)
34 return r
35 }).catch(($, e, next) => next(e))
36 on('session.attach', async ($, e, next) => {
37 const r = await next(e)
38 await log($, 'session.attach', { surface: e.surface })
39 return r
40 }).catch(($, e, next) => next(e))
41 on('turn.start', async ($, e, next) => {
42 await log($, 'turn.start', { text: e.text.slice(0, 40), turnId: e.turnId })
43 return next(e)
44 }).catch(($, e, next) => next(e))
45 on('turn.complete', async ($, e, next) => {
46 await log($, 'turn.complete', { agentId: e.agentId, reason: e.reason, isAborted: e.isAborted, durationMs: e.durationMs, answer: e.answer.slice(0, 60) })
47 return next(e)
48 }).catch(($, e, next) => next(e))
49 on('agent.spawn', async ($, e, next) => {
50 const r = await next(e)
51 await log($, 'agent.spawn', { description: e.description, subagentType: e.subagentType, background: e.background, parentAgentId: e.parentAgentId, got: r })
52 return r
53 }).catch(($, e, next) => next(e))
54 on('session.compact', async ($, e, next) => {
55 await log($, 'session.compact', { trigger: e.trigger, agentId: e.agentId })
56 return next(e)
57 }).catch(($, e, next) => next(e))
58 on('tool.check', async ($, e, next) => {
59 const r = await next(e)
60 await log($, 'tool.check', { tool: e.tool, agentId: e.agentId, real: e.tool_use_id !== undefined, decision: r.decision, reason: r.reason })
61 return r
62 }).catch(($, e, next) => next(e))
63 on('tool.call', async ($, e, next) => {
64 if (String(e.tool) === 'mcp__probe-platform__budget') return { result: lines.slice(-40).join('\n') || 'empty' }
65 await log($, 'tool.call>', { tool: String(e.tool), agentId: e.agentId })
66 const r = await next(e)
67 const res = (r as { result?: unknown }).result
68 await log($, 'tool.call<', { tool: String(e.tool), agentId: e.agentId, isError: (r as { isError?: boolean }).isError, deny: (r as { deny?: string }).deny, bg: (res as { backgroundTaskId?: string } | undefined)?.backgroundTaskId })
69 return r
70 }).catch(($, e, next) => next(e))
71 on('prompt.submit', async ($, e, next) => {
72 await log($, 'prompt.submit', { kind: e.origin.kind, text: e.text.slice(0, 40), turnId: e.turnId })
73 return next(e)
74 }).catch(($, e, next) => next(e))
75 on('prompt.compose', async ($, e, next) => {
76 await log($, 'prompt.compose', { tools: e.tools.length, hud: e.tools.includes('mcp__hud__hud'), taskTools: e.tools.filter(t => /^(TodoWrite|Task)/.test(t)), traits: e.traits })
77 return next(e)
78 }).catch(($, e, next) => next(e))
79 on('classic.PermissionRequest', async ($, e, next) => {
80 await log($, 'classic.PermissionRequest', { tool: e.tool_name, agent: (e as { agent_id?: string }).agent_id, mode: (e as { permission_mode?: string }).permission_mode })
81 return next(e)
82 }).catch(($, e, next) => next(e))
83 on('classic.PermissionDenied', async ($, e, next) => {
84 await log($, 'classic.PermissionDenied', { tool: e.tool_name, reason: e.reason })
85 return next(e)
86 }).catch(($, e, next) => next(e))
87 on('classic.Notification', async ($, e, next) => {
88 await log($, 'classic.Notification', { type: e.notification_type, message: e.message.slice(0, 80) })
89 return next(e)
90 }).catch(($, e, next) => next(e))
91 on('classic.SubagentStart', async ($, e, next) => {
92 await log($, 'classic.SubagentStart', { agent_id: e.agent_id, agent_type: e.agent_type })
93 return next(e)
94 }).catch(($, e, next) => next(e))
95 on('classic.SubagentStop', async ($, e, next) => {
96 await log($, 'classic.SubagentStop', { agent_id: e.agent_id, agent_type: e.agent_type })
97 return next(e)
98 }).catch(($, e, next) => next(e))
99 on('command.run', { command: 'probe-platform' }, async () => ({ text: lines.slice(-60).join('\n') || 'empty' }))
100}
101