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

Replaces the old .claude/hooks/notify-done.sh sound hooks (Stop, Notification, AskUserQuestion) with toasts plus a native notification. No audio, no network.
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.
Toasts only (top-right stack, or the notification bar in scrollback mode).
Run /plugin, find done-notify-mod, and disable or uninstall it.
claude plugin validate .claude/mods/done-notify-mod and claude plugin test .claude/mods/done-notify-mod.
hooks/register.ts 63 lines1import 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