Pings you when a long model turn or a long Bash command ends, so you can look away: /long-run-ping [on|off|status|test].

Tells you when a long job is done, so you can look away.
A model turn longer than afterSec (default 60 s), or a single Bash command longer than commandSec (default 120 s), gets a ping when it ends:
long-run-ping · done after 3m 12ssound is on (default): a short sound and a terminal notificationIt never delays or changes anything: every hook passes the event on first, and any failure inside the mod is swallowed. At most one ping per minGapSec (default 30 s). Interrupted turns and subagent turns are not pinged. It uses no model calls.
/long-run-ping [on|off|status|test] (no word means status)
on / off: the switch is kept in $.store key off and mirrored in the atom { plugin: 'long-run-ping', key: 'isOff' } (boolean, default false), loaded at session start, so the moddeck side bar can read it. When off, nothing happens.status: the thresholds and the last ping.test: plays the ping now.| setting | default | meaning |
|---|---|---|
enabled | true | false makes the mod do nothing |
afterSec | 60 | turn length that earns a ping |
commandSec | 120 | Bash command length that earns a ping |
minGapSec | 30 | least time between pings |
sound | true | sound and terminal notification with the toast; false is toast only |
claude-code.d.ts)$.ui.toast): always used. Verified in tests (mocked).$.audio.play({ asset: 'sounds/ping.wav' }), a 9 KB wav this mod ships): the engine documents that afplay plays it on macOS, and that a Linux or Windows terminal has no player and plays nothing.$.ui.notify): goes through your own preferredNotifChannel (iTerm2, kitty, Ghostty, or the terminal bell). It does nothing without a terminal (a -p run, the SDK, the desktop) or if you disabled notifications.Not verified: that the sound is actually audible and that a notification banner appears. The tests answer $.audio.play and $.ui.notify with mocks, so only the calls are checked, not what a real machine does. Try /long-run-ping test on your own setup.
claude plugin test . and claude plugin validate . in this folder.
hooks/register.tsx 187 lines1import { atom, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4type Dollar = EngineInterface
5
6const COMMAND = 'long-run-ping'
7const NAME = 'long-run-ping'
8const SOUND = 'sounds/ping.wav'
9
10/** Mirror of the persisted on/off switch (`$.store` 'off'), read by the moddeck side bar. */
11const isOff = atom({ plugin: 'long-run-ping', key: 'isOff' } as const, false)
12
13type Cfg = { isEnabled: boolean; afterSec: number; commandSec: number; minGapSec: number; hasSound: boolean }
14
15const num = (v: unknown, fallback: number, min: number): number =>
16 typeof v === 'number' && Number.isFinite(v) && v >= min ? v : fallback
17
18/** "45s", "3m 12s", "1h 2m". */
19function span(ms: number): string {
20 const s = Math.max(0, Math.round(ms / 1000))
21 if (s < 60) return `${s}s`
22 if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`
23 return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
24}
25
26/** What the mod remembers between events. */
27type Seen = { lastAt: number | undefined; lastText: string }
28
29async function isOn($: Dollar, cfg: Cfg): Promise<boolean> {
30 if (!cfg.isEnabled) return false
31 try {
32 return (await $.store.get('off')) !== true
33 } catch {
34 return true
35 }
36}
37
38/** The ping itself: a toast, then (setting `sound`) the sound and the terminal notification. Each part fails alone. */
39async function ring($: Dollar, cfg: Cfg, text: string): Promise<void> {
40 try {
41 $.ui.toast(text)
42 } catch {
43 // a missing toast is harmless
44 }
45 if (!cfg.hasSound) return
46 try {
47 await $.audio.play({ asset: SOUND })
48 } catch {
49 // no player on this machine
50 }
51 try {
52 await $.ui.notify(text)
53 } catch {
54 // no notification channel
55 }
56}
57
58/** Pings if a run of `ms` is past `limitSec` and the gap since the last ping allows. */
59async function maybePing($: Dollar, cfg: Cfg, st: Seen, ms: number, limitSec: number): Promise<void> {
60 try {
61 if (ms <= limitSec * 1000) return
62 if (!(await isOn($, cfg))) return
63 const now = await $.clock.now()
64 if (st.lastAt !== undefined && now - st.lastAt < cfg.minGapSec * 1000) return
65 st.lastAt = now
66 st.lastText = `${NAME} · done after ${span(ms)}`
67 await ring($, cfg, st.lastText)
68 } catch {
69 // bookkeeping only: never get in the way
70 }
71}
72
73async function setSwitch($: Dollar, off: boolean): Promise<void> {
74 if (off) await $.store.set('off', true)
75 else await $.store.delete('off')
76 await update($, isOff, () => off)
77}
78
79async function run($: Dollar, cfg: Cfg, st: Seen, args: string): Promise<string> {
80 const word = args.trim().toLowerCase().replace(/\.$/, '')
81 if (word === 'on' || word === 'off') {
82 if (!cfg.isEnabled) return `${NAME} · disabled · the enabled setting is false (change it in /config)`
83 try {
84 await setSwitch($, word === 'off')
85 } catch {
86 return `${NAME} · that did not work, nothing changed`
87 }
88 return word === 'on' ? `${NAME} · on` : `${NAME} · off · type /long-run-ping on to turn it back on`
89 }
90 if (word === 'test') {
91 if (!(await isOn($, cfg))) return `${NAME} · off · type /long-run-ping on first`
92 await ring($, cfg, `${NAME} · test ping`)
93 return `${NAME} · test ping sent`
94 }
95 if (word === '' || word === 'status') {
96 const isUp = await isOn($, cfg)
97 return [
98 `${NAME} · ${isUp ? 'on' : 'off'}`,
99 `turn over ${cfg.afterSec}s · command over ${cfg.commandSec}s · gap ${cfg.minGapSec}s · sound ${cfg.hasSound ? 'on' : 'off'}`,
100 st.lastText === '' ? 'no ping yet this session' : `last: ${st.lastText}`,
101 ].join('\n')
102 }
103 return `${NAME} · use /long-run-ping on, off, status or test`
104}
105
106
107export const register: Register = (on, options) => {
108 const cfg: Cfg = {
109 isEnabled: options.enabled !== false,
110 afterSec: num(options.afterSec, 60, 1),
111 commandSec: num(options.commandSec, 120, 1),
112 minGapSec: num(options.minGapSec, 30, 0),
113 hasSound: options.sound !== false,
114 }
115 let turnStart: number | undefined
116 const st: Seen = { lastAt: undefined, lastText: '' }
117
118 on('session.start', async ($, e, next) => {
119 try {
120 const stored = (await $.store.get('off')) === true
121 await update($, isOff, () => stored)
122 } catch {
123 // the switch then reads as on
124 }
125 try {
126 await $.command.register({
127 name: COMMAND,
128 description: 'Ping when a long turn or command ends: /long-run-ping [on|off|status|test]',
129 argumentHint: '[on|off|status|test]',
130 immediate: true,
131 })
132 } catch {
133 // fail-open: without the command the mod still pings
134 }
135 return next(e)
136 })
137
138 on('prompt.submit', async ($, e, next) => {
139 let t: number | undefined
140 try {
141 t = await $.clock.now()
142 } catch {
143 // no start, no ping for this turn
144 }
145 const out = await next(e)
146 turnStart = t
147 return out
148 })
149
150 on('turn.complete', async ($, e, next) => {
151 const out = await next(e)
152 try {
153 const ev = e as { agentId?: string; isAborted?: boolean; durationMs?: number }
154 if (ev.agentId === undefined && ev.isAborted !== true) {
155 const start = turnStart
156 turnStart = undefined
157 let ms = ev.durationMs
158 if (start !== undefined) ms = (await $.clock.now()) - start
159 if (typeof ms === 'number') await maybePing($, cfg, st, ms, cfg.afterSec)
160 }
161 } catch {
162 // bookkeeping only
163 }
164 return out
165 })
166
167 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
168 let t: number | undefined
169 try {
170 t = await $.clock.now()
171 } catch {
172 // no start, no ping for this command
173 }
174 const out = await next(e)
175 try {
176 if (t !== undefined) await maybePing($, cfg, st, (await $.clock.now()) - t, cfg.commandSec)
177 } catch {
178 // bookkeeping only
179 }
180 return out
181 })
182
183 on('command.run', { command: COMMAND }, async ($, e) => ({ text: await run($, cfg, st, e.args) })).catch(() => ({
184 text: `${NAME} · nothing changed`,
185 }))
186}
187types/index.d.ts 9 lines1declare module 'claude-code' {
2 interface PluginState {
3 'long-run-ping': {
4 /** The persisted on/off switch, mirrored here so the moddeck side bar can read it. */
5 isOff: boolean
6 }
7 }
8}
9