SLOPSHOPPER

done-notify-mod

Toasts elapsed time and token usage when a turn ends, a notification is raised, or a question waits; also sends a native notification.

newguardtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · done-notify-mod
› fix the failing auth test and add an audit log call ╭──────────────────────────────╮ │ done-notify-mod │ ⏺ Read(src/auth.ts) │ Done in 0m42s · 98880 tokens │ ⎿ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

done-notify-mod

Replaces the old .claude/hooks/notify-done.sh sound hooks (Stop, Notification, AskUserQuestion) with toasts plus a native notification. No audio, no network.

What it hooks

  • turn.start / turn.step (main loop only, agentId === undefined): records start time and sums per-step usage.
  • turn.complete (main loop): toast Done in <m>m<s>s · <tokens> tokens, plus $.ui.notify.
  • classic.Notification (the engine's Notification hook event): toast Notification: <message> · <elapsed> · <tokens>.
  • tool.call with AskUserQuestion: before the call runs, toast Question waiting · <elapsed> · <tokens>, plus $.ui.notify.

Tokens = input + output + cache read + cache creation (write). Elapsed uses e.durationMs on completion, else the $.clock.now() delta from turn start. $.ui.notify failures / { isSent: false } are ignored silently.

What it draws

Toasts only (top-right stack, or the notification bar in scrollback mode).

Disable

Run /plugin, find done-notify-mod, and disable or uninstall it.

Test

claude plugin validate .claude/mods/done-notify-mod and claude plugin test .claude/mods/done-notify-mod.

Source 1 files
hooks/register.ts 63 lines
1import type { Register, TurnUsage } from 'claude-code'
2
3// Main-loop turn tracking only (agentId === undefined). Kept in module scope.
4let startedAt: number | undefined
5let tokens = 0
6
7/** Tokens summed: input + output + cache read + cache creation (write). */
8export const sumUsage = (u?: TurnUsage): number =>
9  u ? u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens : 0
10
11export const fmtElapsed = (ms: number): string => {
12  const s = Math.max(0, Math.round(ms / 1000))
13  return `${Math.floor(s / 60)}m${s % 60}s`
14}
15
16export const register: Register = (on) => {
17  const elapsed = (now: number): string => fmtElapsed(startedAt === undefined ? 0 : now - startedAt)
18  const quietNotify = async (n: (t: string) => Promise<unknown>, text: string) => {
19    try { await n(text) } catch { /* no surface / refused: ignore */ }
20  }
21
22  on('turn.start', async ($, e, next) => {
23    if (e.agentId === undefined) { startedAt = await $.clock.now(); tokens = 0 }
24    return next(e)
25  })
26
27  // Per-step usage so "tokens so far" is live mid-turn.
28  on('turn.step', async function* ($, e, next) {
29    const r = yield* next(e)
30    if (e.agentId === undefined) tokens += sumUsage(r.usage ?? undefined)
31    return r
32  })
33
34  on('turn.complete', async ($, e, next) => {
35    if (e.agentId !== undefined) return next(e)
36    const now = await $.clock.now()
37    const ms = typeof e.durationMs === 'number' ? e.durationMs : now - (startedAt ?? now)
38    const t = e.usage ? sumUsage(e.usage) : tokens
39    const text = `Done in ${fmtElapsed(ms)} · ${t} tokens`
40    $.ui.toast(text)
41    await quietNotify((x) => $.ui.notify(x), text)
42    startedAt = undefined
43    return next(e)
44  })
45
46  // Engine-raised notifications arrive as the settings hook event `Notification`.
47  on('classic.Notification', async ($, e, next) => {
48    if (!e.message.startsWith('Question waiting') && !e.message.startsWith('Done in')) {
49      $.ui.toast(`Notification: ${e.message} · ${elapsed(await $.clock.now())} · ${tokens} tokens`)
50    }
51    return next(e)
52  }).catch(($, e, next) => (next.called ? {} : next(e)))
53
54  on('tool.call', async ($, e, next) => {
55    if (e.tool === 'AskUserQuestion') {
56      const text = `Question waiting · ${elapsed(await $.clock.now())} · ${tokens} tokens`
57      $.ui.toast(text)
58      void quietNotify((x) => $.ui.notify(x), text)
59    }
60    return next(e)
61  }).catch(($, e, next) => (next.called ? { result: { text: '', isError: false } } : next(e)))
62}
63