SLOPSHOPPER

probe-platform

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

newguardcommandprompttoolagents
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · probe-platform
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /probe-platform ⎿ probe-platform: 2025-10-09T08:53:20.020Z session.start {"version":{"version":"2.1.289","base":"2.1.289","builtAt":"2026-10-03T ⎿ probe-platform: 2025-10-09T08:53:20.040Z prompt.submit {"kind":"composer","text":"fix the failing auth test and add an aud"} ⎿ probe-platform: 2025-10-09T08:53:20.060Z tool.call> {"tool":"Read"} ⎿ probe-platform: 2025-10-09T08:53:20.080Z tool.call< {"tool":"Read","isError":false} ⎿ probe-platform: 2025-10-09T08:53:20.100Z tool.call> {"tool":"Grep"} ⎿ probe-platform: 2025-10-09T08:53:20.120Z tool.call< {"tool":"Grep","isError":false} ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
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 1 files
hooks/register.ts 101 lines
1import 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