SLOPSHOPPER

done-ping

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

newguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · done-ping
› fix the failing auth test and add an audit log call ╭───────────────────────────────────────╮ │ done-ping │ ⏺ Read(src/auth.ts) │ Claude finished in 42s · 9 tool calls │ ⎿ 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 › /done-ping ⎿ done-ping: Notifies when a turn of 30s or more finishes. /done-ping test sends a sample. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

done-ping

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

What it draws

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     │
└──────────────────────────────────────────────┘
  • It says how long the turn took and how many tool calls the main conversation made in it. A subagent's own calls are not counted.
  • A turn you interrupted (Esc) is never announced.
  • A turn that died on an API error reads Claude stopped on an error after 4m 12s · 9 tool calls.
  • A subagent's own end is not announced. Only the main conversation's turn is.
  • When the notification is not sent, for example with notifications turned off or a terminal with no notification channel, the same line shows as a toast instead.

Commands

CommandWhat it does
/done-ping testSends a sample notification and says what happened.
/done-pingSays 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.

Options

OptionTypeDefaultWhat it does
minSecondsnumber30Notify only when a turn took at least this many seconds.

Set it with /config, or in settings under pluginConfigs["done-ping@mod-showroom"].options.

Install

  • Terminal: /plugin install done-ping --marketplace KROU4/claude-code-mod-showroom
  • Shell: claude plugin install done-ping --marketplace KROU4/claude-code-mod-showroom
  • Desktop app: + next to the prompt box → Plugins → Add marketplace KROU4/claude-code-mod-showroom → Add plugin

Limits

  • The desktop app holds no terminal to notify through, so there you get the toast only.
  • With the terminal_bell channel the notification is a bell with no text. /done-ping test says so.

License

MIT. See LICENSE.

Source 1 files
hooks/register.ts 102 lines
1import 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