Toasts when a long turn finishes or Claude is waiting on you, and shows a 'working 3m · 14 tools' heartbeat during long turns.

Tells you when Claude needs you, for when you walk away during a long run.
thresholdSeconds (userConfig, default 60) raises a toast on finish: Turn done in 3m 12s · 14 tools (or Turn ended (error) after ...). Interrupted turns stay quiet.Notification event with permission_prompt), toasts waiting for you: ....working 3m · 14 tools updates every 15 s and clears when the turn ends. Tool counts include subagents' calls.Limits: no sound. $.audio.play needs an asset, URL or bytes and plays nothing on a Linux terminal; $.audio.speak needs a platform synthesizer. Toasts stay 30 s but only show on screen. A hot reload mid-turn loses that turn's heartbeat and toast.
hooks/register.ts 91 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3const HEARTBEAT_AFTER_MS = 120_000
4const TICK_MS = 15_000
5const TOAST_MS = 30_000
6
7// What each classic Notification type means for someone who is not looking.
8const WAITING: Record<string, string> = {
9 permission_prompt: 'waiting for you: permission needed',
10 elicitation_dialog: 'waiting for you: input requested',
11}
12
13export function duration(ms: number): string {
14 const s = Math.round(ms / 1000)
15 if (s < 60) {
16 return `${s}s`
17 }
18 const m = Math.floor(s / 60)
19 return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`
20}
21
22export function heartbeat(elapsedMs: number, tools: number): string {
23 return `working ${Math.floor(elapsedMs / 60_000)}m · ${tools} tool${tools === 1 ? '' : 's'}`
24}
25
26// The running turn's bookkeeping. Nothing draws from it, so a hot reload losing
27// it only costs the heartbeat and toast of the turn running at that moment.
28type Live = { startedAt?: number; tools: number; tick?: Timer }
29
30async function beat($: EngineInterface, live: Live): Promise<void> {
31 if (live.startedAt === undefined) {
32 return
33 }
34 const elapsed = (await $.clock.now()) - live.startedAt
35 if (elapsed >= HEARTBEAT_AFTER_MS) {
36 $.ui.status(heartbeat(elapsed, live.tools))
37 }
38}
39
40export const register: Register = (on, options) => {
41 const thresholdMs = Number(options.thresholdSeconds ?? 60) * 1000
42
43 const live: Live = { tools: 0 }
44
45 on('turn.start', async ($, e, next) => {
46 live.startedAt = await $.clock.now()
47 live.tools = 0
48 live.tick?.cancel()
49 live.tick = $.clock.every(TICK_MS, () => {
50 beat($, live).catch(() => {})
51 })
52 return next(e)
53 })
54
55 on('tool.call', ($, e, next) => {
56 if (live.startedAt !== undefined) {
57 live.tools += 1
58 }
59 if (e.tool === 'AskUserQuestion') {
60 $.ui.toast('waiting for you: a question', { timeoutMs: TOAST_MS })
61 }
62 return next(e)
63 }).catch(($, e, next) => next(e))
64
65 on('classic.Notification', ($, e, next) => {
66 const text = WAITING[e.notification_type]
67 if (text !== undefined) {
68 $.ui.toast(text, { timeoutMs: TOAST_MS })
69 }
70 return next(e)
71 }).catch(($, e, next) => next(e))
72
73 on('turn.complete', async ($, e, next) => {
74 // Subagent runs end their own turns inside the main one.
75 if (e.agentId !== undefined) {
76 return next(e)
77 }
78 const count = live.tools
79 live.tick?.cancel()
80 live.tick = undefined
81 live.startedAt = undefined
82 $.ui.status(undefined)
83
84 if (!e.isAborted && e.durationMs >= thresholdMs) {
85 const verb = e.reason === 'answer' ? 'Turn done in' : `Turn ended (${e.reason}) after`
86 $.ui.toast(`${verb} ${duration(e.durationMs)} · ${count} tool${count === 1 ? '' : 's'}`, { timeoutMs: TOAST_MS })
87 }
88 return next(e)
89 })
90}
91