SLOPSHOPPER

notify

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.

newguardtoastprocess
v0.2.1no licenseupdated 2026-10-08oleksandrkodua/qa-mods/notify
A shopper browsing a rack in a slop shop
README

qa-mods

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.

Install

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.

Plugins

PluginWhat it does
sandbox-guardDenies writes to protected Claude config paths and hands back a Terminal command
remote-gateAsks before git push and wrangler --remote, showing folder, branch, remote and the commits that will go out
blast-radiusShows what a risky Bash command will touch before it runs
retry-analyzerDetects repeated identical failing tool calls and tells Claude to change strategy
secret-redactorMasks tokens and keys in tool output
evidence-saverSaves screenshots from tool results to a folder you pick
replay-theater/replay: step through the session's file edits one by one
verification-guardChecks claims like "tests pass" against tool calls that actually ran
handoffOffers /handoff as the context fills up; fills the prompt box with a HANDOFF.md prompt
notifyA toast and a macOS notification when a long command or turn finishes
quick-actionsCompact and Clear buttons, each asks to confirm first
hudContext 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-ukFork of next-steps (MIT, Thariq Shihipar): suggested next prompts are always in Ukrainian. Install instead of next-steps
plan-progressFork of plan-progress 0.7.6 (zycck, MIT): live progress bars above the prompt. Install instead of plan-progress@zycck-mods

Tests

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.

License

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).

Source 2 files
hooks/register.ts 78 lines
1import 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}
78
hooks/format.ts 19 lines
1/** `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