A native notification when a long turn finishes, so you can look away.

A native notification when a long turn finishes, so you can look away.
When a turn ends after at least minSeconds (30 by default), a notification goes out through your notification channel (the preferredNotifChannel setting):
┌ Claude Code ─────────────────────────────────┐
│ Claude finished in 3m 5s · 31 tool calls │
└──────────────────────────────────────────────┘
Claude stopped on an error after 4m 12s · 9 tool calls.| Command | What it does |
|---|---|
/done-ping test | Sends a sample notification and says what happened. |
/done-ping | Says when it notifies, and sends nothing. |
> /done-ping test
Sample notification sent via kitty.
> /done-ping test
Sample notification not sent: no-channel, shown as a toast instead.
| Option | Type | Default | What it does |
|---|---|---|---|
minSeconds | number | 30 | Notify only when a turn took at least this many seconds. |
Set it with /config, or in settings under pluginConfigs["done-ping@mod-showroom"].options.
/plugin install done-ping --marketplace KROU4/claude-code-mod-showroomclaude plugin install done-ping --marketplace KROU4/claude-code-mod-showroomKROU4/claude-code-mod-showroom → Add pluginterminal_bell channel the notification is a bell with no text. /done-ping test says so.MIT. See LICENSE.
hooks/register.ts 102 lines1import type { EngineInterface, Register, TurnCompleteReason } from 'claude-code'
2
3const TITLE = 'Claude Code'
4const SAMPLE = { ms: 100_000, tools: 12, reason: 'answer' } as const
5
6// 45s, 1m 40s, 2m, 1h 5m
7function duration(ms: number): string {
8 const total = Math.round(ms / 1000)
9 const h = Math.floor(total / 3600)
10 const m = Math.floor((total % 3600) / 60)
11 const s = total % 60
12
13 if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`
14 if (m > 0) return s > 0 ? `${m}m ${s}s` : `${m}m`
15 return `${s}s`
16}
17
18function summary(ms: number, tools: number, reason: TurnCompleteReason): string {
19 const calls = `${tools} tool call${tools === 1 ? '' : 's'}`
20 const head = reason === 'error' ? 'Claude stopped on an error after' : 'Claude finished in'
21
22 return `${head} ${duration(ms)} · ${calls}`
23}
24
25// Notifies natively; anything short of a sent notification becomes a toast.
26// Answers what happened, for /done-ping test.
27async function ping($: EngineInterface, text: string): Promise<string> {
28 let reason: string
29 try {
30 const sent = await $.ui.notify(text, { title: TITLE })
31 if (sent.isSent) {
32 return sent.channel === 'terminal_bell'
33 ? 'sent via terminal_bell (a bell only, no text)'
34 : `sent via ${sent.channel}`
35 }
36 reason = sent.reason
37 } catch (error) {
38 reason = error instanceof Error ? error.message : String(error)
39 }
40 $.ui.toast(text)
41
42 return `not sent: ${reason}, shown as a toast instead`
43}
44
45export const register: Register = (on, options) => {
46 const minSeconds = typeof options.minSeconds === 'number' ? options.minSeconds : 30
47 let tools = 0
48
49 on('session.start', async ($, e, next) => {
50 await $.command.register({
51 name: 'done-ping',
52 description: 'Send a sample turn-finished notification',
53 argumentHint: 'test',
54 })
55
56 return next(e)
57 })
58
59 // Counting never guards: should a counter fail, the prompt or call goes on.
60 // A prompt typed over a running turn must not zero that turn's count; if it
61 // was queued, its own turn starts from the zero turn.complete leaves.
62 on('prompt.submit', ($, e, next) => {
63 if (e.turnId === undefined) tools = 0
64
65 return next(e)
66 }).catch(($, e, next) => next(e))
67
68 // The main loop's calls alone: subagents, background agents and the
69 // engine's own forks (compaction, memory) all run with an agentId.
70 on('tool.call', ($, e, next) => {
71 if (e.agentId === undefined) tools += 1
72
73 return next(e)
74 }).catch(($, e, next) => next(e))
75
76 on('turn.complete', async ($, e, next) => {
77 if (e.agentId !== undefined) return next(e)
78
79 // Taken before the chain goes on: a call a hook beneath makes is not this turn's.
80 const calls = tools
81 tools = 0
82 const done = await next(e)
83
84 if (!e.isAborted && e.durationMs >= minSeconds * 1000) {
85 await ping($, summary(e.durationMs, calls, e.reason))
86 }
87
88 return done
89 })
90
91 on('command.run', { command: 'done-ping' }, async ($, e) => {
92 if (e.args.trim() !== 'test') {
93 return {
94 text: `Notifies when a turn of ${minSeconds}s or more finishes. /done-ping test sends a sample.`,
95 }
96 }
97 const what = await ping($, summary(SAMPLE.ms, SAMPLE.tools, SAMPLE.reason))
98
99 return { text: `Sample notification ${what}.` }
100 })
101}
102