SLOPSHOPPER

jr-done-ping

A notification, and a sound on macOS, when a long turn finishes: work in another window and come back when Claude is done. Names the project and how long it…

newprocess
★ 2v0.1.0MITupdated 2026-10-15jrlprost/claude-code-mods/plugins/jr-done-ping
A shopper browsing a rack in a slop shop
README

jr-done-ping

Start a long task, go do something else, get called back when Claude is done.

jr done
shop: done in 4 min 12 s
  • A notification through your own notification channel when a main-loop turn longer than 2 minutes finishes, with the project name and the duration.
  • On macOS, a system sound with it.
  • Interrupted turns and subagents stay silent; a turn that stopped on an error says so.

Settings (/config)

OptionDefault
Only after (seconds)120Minimum 5
Sound (macOS)Glassnone, Glass, Hero, Ping, Submarine, Funk, Purr

Install

/plugin install jr-done-ping --marketplace jrlprost/claude-code-mods

Access

Raises notifications. Runs afplay with a sound from /System/Library/Sounds. Sends nothing over the network.

Source 2 files
hooks/register.ts 28 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { message, shouldPing, soundFile } from './ping'
4
5async function ping($: EngineInterface, text: string, sound: string | null) {
6  await $.ui.notify(text, { title: 'jr done' })
7  if (!sound) return
8  try {
9    await $.process.run(['afplay', sound])
10  } catch {
11    // No afplay off macOS: the notification alone stands.
12  }
13}
14
15export const register: Register = (on, options) => {
16  const minSeconds = Math.max(5, Number(options.minSeconds ?? 120) || 120)
17  const sound = soundFile(String(options.sound ?? 'Glass'))
18
19  on('turn.complete', async ($, e, next) => {
20    const done = await next(e)
21    if (!shouldPing(e, minSeconds)) return done
22    const root = await $.session.root()
23    const project = root.split('/').filter(Boolean).pop() ?? root
24    await ping($, message(project, e), sound)
25    return done
26  })
27}
28
hooks/ping.ts 20 lines
1export const SOUNDS = ['none', 'Glass', 'Hero', 'Ping', 'Submarine', 'Funk', 'Purr'] as const
2
3/** "42 s", "4 min 12 s", "1 h 05 min" */
4export const spoken = (ms: number) => {
5  const s = Math.round(ms / 1000)
6  if (s < 60) return `${s} s`
7  if (s < 3600) return `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')} s`
8  return `${Math.floor(s / 3600)} h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')} min`
9}
10
11type Done = { durationMs: number; isAborted: boolean; agentId?: string; reason?: string }
12
13/** Ping for the main loop's long turns that ended on their own (not interrupted). */
14export const shouldPing = (e: Done, minSeconds: number) => e.agentId === undefined && !e.isAborted && e.durationMs >= minSeconds * 1000
15
16export const message = (project: string, e: Done) =>
17  e.reason === 'error' ? `${project}: stopped on an error after ${spoken(e.durationMs)}` : `${project}: done in ${spoken(e.durationMs)}`
18
19export const soundFile = (sound: string) => (sound === 'none' || !SOUNDS.includes(sound as never) ? null : `/System/Library/Sounds/${sound}.aiff`)
20