SLOPSHOPPER

Done notify

A desktop notification when a long turn ends, telling you whether Claude is done or waiting for you

newprocess
★ 1v0.1.0MITupdated 2026-10-21benjaminb10/encore-mods/plugins/done-notify
A shopper browsing a rack in a slop shop
README

Done notify

A Claude Code mod from Encore Mods. Look away while Claude works.

  • A desktop notification when a turn that took at least 15 seconds ends.
  • Claude is waiting for you when the reply ends with a question or a list of choices, Claude is done otherwise, with the reply's first line.
  • On macOS, clicking it brings back the terminal app you run Claude in.
  • Uses terminal-notifier when installed (brew install terminal-notifier), then osascript, then notify-send on Linux.

Install

/plugin marketplace add benjaminb10/encore-mods
/plugin install done-notify@encore-mods

Then /reload-plugins or restart Claude Code. Needs Claude Code 2.1.287 or later.

Options

min_seconds: the shortest turn that notifies (default 15). Set it in /plugin, or with /plugin configure done-notify@encore-mods.

Try it without installing

git clone https://github.com/benjaminb10/encore-mods
claude --plugin-dir encore-mods/plugins/done-notify
Source 1 files
hooks/register.js 55 lines
1// done-notify: a desktop notification when a turn that took a while ends, so you can look away while
2// Claude works. "Claude is waiting for you" when the reply ends with a question or a list of choices,
3// "Claude is done" otherwise. On macOS, clicking it brings back the terminal app you run Claude in.
4
5let minMs = 15000
6
7function clean(s) {
8  return String(s).replace(/\*\*|__|`|#+\s*/g, '').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/\s+/g, ' ').trim()
9}
10
11export function summary(answer) {
12  const first = clean((answer || '').split('\n').find((l) => l.trim()) || 'Turn finished')
13  return first.length > 140 ? first.slice(0, 139) + '…' : first
14}
15
16export function isWaiting(answer) {
17  const text = (answer || '').trim()
18  if (/\?\s*\**$/.test(text)) return true
19  const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
20  const tail = []
21  while (lines.length && /^(?:\d+[.)]|[-*•])\s+/.test(lines[lines.length - 1])) tail.unshift(lines.pop())
22  return tail.length >= 2 && lines.length > 0 && /[?:]\s*\**$/.test(lines[lines.length - 1])
23}
24
25async function notify($, title, message) {
26  const app = $.env.get('__CFBundleIdentifier')
27  const tn = ['-title', title, '-message', message, '-sound', 'Glass', '-group', 'done-notify-' + $.session.id()]
28  if (app) tn.push('-activate', app)
29  const tries = [
30    ['terminal-notifier', ...tn],
31    ['/opt/homebrew/bin/terminal-notifier', ...tn],
32    ['/usr/local/bin/terminal-notifier', ...tn],
33    ['osascript', '-e', 'display notification ' + JSON.stringify(message) + ' with title ' + JSON.stringify(title) + ' sound name "Glass"'],
34    ['notify-send', title, message],
35  ]
36  for (const cmd of tries) {
37    try {
38      const r = await $.process.run(cmd)
39      if (r.exitCode === 0) return
40    } catch {}
41  }
42}
43
44export function register(on, options = {}) {
45  const s = Number(options.min_seconds)
46  if (Number.isFinite(s) && s >= 0) minMs = s * 1000
47
48  on('turn.complete', async ($, e, next) => {
49    if (!e.agentId && !e.isAborted && e.durationMs >= minMs) {
50      await notify($, isWaiting(e.answer) ? 'Claude is waiting for you' : 'Claude is done', summary(e.answer))
51    }
52    return next(e)
53  })
54}
55