A quick pop-up (toast, plus a macOS system notification) when a long Bash command or a long turn finishes, so you can stop watching it.

Fourteen Claude Code mods, packaged as one plugin marketplace (qa-mods). Made for a manual QA workflow: guard rails around risky commands, evidence for what was tested, and a status band above the prompt.
Run in your own Terminal (a sandboxed Claude session cannot write ~/.claude).
claude plugin marketplace add oleksandrkodua/qa-mods
claude plugin install hud@qa-mods
Replace hud with any plugin from the table.
To install everything from a local clone, or to set up a fresh machine, see INSTALL.md and install-pack.sh.
| Plugin | What it does |
|---|---|
sandbox-guard | Denies writes to protected Claude config paths and hands back a Terminal command |
remote-gate | Asks before git push and wrangler --remote, showing folder, branch, remote and the commits that will go out |
blast-radius | Shows what a risky Bash command will touch before it runs |
retry-analyzer | Detects repeated identical failing tool calls and tells Claude to change strategy |
secret-redactor | Masks tokens and keys in tool output |
evidence-saver | Saves screenshots from tool results to a folder you pick |
replay-theater | /replay: step through the session's file edits one by one |
verification-guard | Checks claims like "tests pass" against tool calls that actually ran |
handoff | Offers /handoff as the context fills up; fills the prompt box with a HANDOFF.md prompt |
notify | A toast and a macOS notification when a long command or turn finishes |
quick-actions | Compact and Clear buttons, each asks to confirm first |
hud | Context fill and rate-limit windows above the prompt, a кеш 59:48 chip (m:ss) while the prompt cache is warm, five buttons, and /hud with the figures as text |
next-steps-uk | Fork of next-steps (MIT, Thariq Shihipar): suggested next prompts are always in Ukrainian. Install instead of next-steps |
plan-progress | Fork of plan-progress 0.7.6 (zycck, MIT): live progress bars above the prompt. Install instead of plan-progress@zycck-mods |
claude plugin test hud # from the repo root
cd hud && node --test tests/format.test.mjs tests/handoff-prompt.test.mjs
A bare node --test also picks up register.test.ts, which only runs under claude plugin test.
No license file for the original mods yet. The two forks keep their upstream MIT notices: next-steps-uk/NOTICE.md and plan-progress/NOTICE.md (plus plan-progress/LICENSE.upstream).
hooks/register.ts 78 lines1import type { Register } from 'claude-code'
2
3import { duration, oneLine } from './format'
4
5/**
6 * Notify: a quick pop-up when something long finishes, so you can stop watching it.
7 * - a Bash command that ran at least `longCommandSeconds` (default 30): a build, a test run, an install;
8 * - a whole turn that ran at least `longTurnSeconds` (default 120).
9 * Each notice is a toast and, on macOS, also a system notification (`systemNotification`), because a toast
10 * lives inside the window and the point is to be reached when you are looking at something else.
11 * Not covered, on purpose: a command started with run_in_background returns at once and its end is NOT announced
12 * (0.2.0 did announce it as `Background task finished: ...`; removed in 0.2.1, the text was an unreadable id and path).
13 * A command that waited for an approval dialog counts that wait as running time.
14 */
15export const register: Register = (on, options) => {
16 const cmdAfter = Number(options?.longCommandSeconds) > 0 ? Number(options.longCommandSeconds) : 30
17 const turnAfter = Number(options?.longTurnSeconds) > 0 ? Number(options.longTurnSeconds) : 120
18 const system = options?.systemNotification !== false
19 let isMac = false
20 let turnStartedAt = 0
21 let lastNoticeAt = 0
22
23 on('session.start', async ($, e, next) => {
24 if (system) {
25 const r = await $.process.run(['uname']).catch(() => undefined)
26
27 isMac = String(r?.stdout ?? '').trim() === 'Darwin'
28 }
29
30 return next(e)
31 })
32
33 on('turn.start', async ($, e, next) => {
34 turnStartedAt = await $.clock.now()
35
36 return next(e)
37 })
38
39 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
40 const started = await $.clock.now()
41 const ran = await next(e)
42 const ended = await $.clock.now()
43 const seconds = (ended - started) / 1000
44
45 if (seconds >= cmdAfter && ran.deny === undefined) {
46 const text = `${ran.isError === true ? '✘ Command failed after' : '✔ Command finished in'} ${duration(seconds)}: ${oneLine(String(e.command ?? ''), 60)}`
47
48 lastNoticeAt = ended
49 $.ui.toast(text, { timeoutMs: 8000 })
50
51 if (isMac) {
52 await $.process.run(['osascript', '-e', `display notification "${oneLine(text, 120)}" with title "Claude Code"`]).catch(() => undefined)
53 }
54 }
55
56 return ran
57 })
58
59 on('turn.complete', async ($, e, next) => {
60 const ended = await $.clock.now()
61 const seconds = (ended - turnStartedAt) / 1000
62
63 // the end of a long command was just announced: do not announce the turn right behind it
64 if (e.agentId === undefined && turnStartedAt > 0 && seconds >= turnAfter && e.reason !== 'aborted' && ended - lastNoticeAt > 15000) {
65 const text = `${e.reason === 'answer' ? '✔ Claude finished' : '✘ Claude stopped'} after ${duration(seconds)}`
66
67 lastNoticeAt = ended
68 $.ui.toast(text, { timeoutMs: 8000 })
69
70 if (isMac) {
71 await $.process.run(['osascript', '-e', `display notification "${oneLine(text, 120)}" with title "Claude Code"`]).catch(() => undefined)
72 }
73 }
74
75 return next(e)
76 })
77}
78hooks/format.ts 19 lines1/** `45s`, `1m 12s`, `1h 05m`: how long something took, for a one-line notice. */
2export function duration(totalSeconds: number): string {
3 const s = Math.max(0, Math.round(totalSeconds))
4 const h = Math.floor(s / 3600)
5 const m = Math.floor((s % 3600) / 60)
6 const r = s % 60
7
8 if (h > 0) return `${h}h ${String(m).padStart(2, '0')}m`
9
10 return m > 0 ? `${m}m ${String(r).padStart(2, '0')}s` : `${r}s`
11}
12
13/** One line, no quotes or backslashes (it goes into an AppleScript string), cut to `max` characters. */
14export function oneLine(text: string, max = 80): string {
15 const t = text.replace(/\s+/g, ' ').replace(/["\\]/g, '').trim()
16
17 return t.length > max ? `${t.slice(0, max - 1)}…` : t
18}
19