SLOPSHOPPER

long-run-ping

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].

newguardcommandtoastpromptaudio
★ 1v0.1.0MITupdated 2026-10-12samuelbfernandes/moddeck/mods/long-run-ping
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · long-run-ping
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 › /long-run-ping ⎿ long-run-ping: long-run-ping · on ⎿ long-run-ping: turn over 60s · command over 120s · gap 30s · sound on ⎿ long-run-ping: no ping yet this session ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

long-run-ping

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:

  • a toast: long-run-ping · done after 3m 12s
  • if sound is on (default): a short sound and a terminal notification

It 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.

Commands

/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.

Settings

settingdefaultmeaning
enabledtruefalse makes the mod do nothing
afterSec60turn length that earns a ping
commandSec120Bash command length that earns a ping
minGapSec30least time between pings
soundtruesound and terminal notification with the toast; false is toast only

Ping channels (from the engine's types, claude-code.d.ts)

  • Toast ($.ui.toast): always used. Verified in tests (mocked).
  • Sound ($.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.
  • Terminal notification ($.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.

Tests

claude plugin test . and claude plugin validate . in this folder.

Source 2 files
hooks/register.tsx 187 lines
1import { 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}
187
types/index.d.ts 9 lines
1declare 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