SLOPSHOPPER

notify-on-finish

Sends a desktop notification when a long turn finishes, so you can look away while Claude works.

newtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · notify-on-finish
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ notify-on-finish │ ⏺ Read(src/auth.ts) │ app: Done. I made `refresh` reject expired │ ⎿ Read 6 lines │ claims, added an audit call, and created │ ⏺ Update(src/auth.ts) │ `src/audit.ts`. One t │ ⎿ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Notify on Finish

Sends a desktop notification when a long turn finishes, titled with the repo or folder name and carrying the first ~100 characters of Claude's answer.

What this shows

A turn.complete hook that reads durationMs, isAborted and agentId, and shells out through $.process.run with argv only (no shell). The notification text travels as AppleScript argv items, so quotes, backslashes and newlines never become code.

Demo

Not captured: a notification banner can't be seen in claude -p. The argv the mod builds was run directly with osascript on macOS and raised a banner.

How it works

  • Ignores subagent turns (e.agentId), aborted turns, and turns at or under threshold_seconds.
  • Detects the OS once with uname -s: Darwin uses osascript, Linux uses notify-send, anything else (or a failed uname) uses $.ui.toast.
  • Title: basename of $.session.cwd(). Body: the answer with whitespace collapsed, cut at 100 characters; an empty answer gives Finished in Ns.
  • Never throws and always calls next(e); a failed notification command is swallowed.
hooks: turn.complete
calls: $.process.run, $.session.cwd, $.ui.toast

Run it

Requires a Claude Code version with mods. On macOS nothing else is needed; on Linux install notify-send (libnotify).

claude --plugin-dir ./mods/notify-on-finish

Config (userConfig):

KeyDefaultMeaning
threshold_seconds20Notify only when the turn took longer. Non-numeric falls back to 20.
soundemptymacOS sound name (Glass, Ping, ...). Empty is silent.

Notes / limitations

  • only_when_unfocused (skip the notification while the terminal is focused) is out of scope: the mods API has no focus signal.
  • macOS banners are attributed to Script Editor, and need notifications allowed for it in System Settings.
  • Windows has no native path; it gets the in-app toast.
  • Only the main loop notifies; subagent completions are ignored.
  • The official test kit loads the mod with default options only, so sound and a custom threshold are not covered by tests.
  • Does not use the AbovePrompt band.

Dependencies

None.

Source 1 files
hooks/notify-on-finish.mjs 54 lines
1// Notify on Finish: desktop notification when a long main-loop turn ends.
2//
3// turn.complete: if the turn ran longer than threshold_seconds and was not
4// interrupted, notify via osascript (macOS), notify-send (Linux) or a toast.
5// Text is passed as argv, never interpolated into a script or shell string.
6//
7// The host reads `on(...)` and `$.noun.method(...)` from source, so they are
8// spelled literally.
9
10let platform; // ponytail: cached per load; a failed uname is retried next turn
11
12async function os($) {
13  if (platform) return platform;
14  try {
15    const r = await $.process.run(["uname", "-s"]);
16    if (r.exitCode === 0) platform = r.stdout.trim();
17  } catch {}
18  return platform;
19}
20
21export function register(on, options) {
22  on("turn.complete", async ($, e, next) => {
23    try {
24      const limit = Number(options?.threshold_seconds ?? 20);
25      const secs = Math.round(e.durationMs / 1000);
26      // ponytail: a non-numeric threshold falls back to 20 s
27      if (e.agentId || e.isAborted || e.durationMs <= (Number.isFinite(limit) ? limit : 20) * 1000) return next(e);
28
29      const cwd = String(await $.session.cwd()).replace(/\/+$/, "");
30      const title = cwd.split("/").pop() || "Claude Code";
31      const flat = (e.answer || "").replace(/\s+/g, " ").trim();
32      const body = flat ? flat.slice(0, 100) : `Finished in ${secs}s`;
33
34      const sys = await os($);
35      if (sys === "Darwin") {
36        // ponytail: osascript attributes the banner to Script Editor, not Claude Code
37        const sound = String(options?.sound ?? "").trim();
38        await $.process.run([
39          "osascript",
40          "-e", "on run argv",
41          "-e", "display notification (item 1 of argv) with title (item 2 of argv)" + (sound ? " sound name (item 3 of argv)" : ""),
42          "-e", "end run",
43          "--", body, title, ...(sound ? [sound] : []),
44        ]);
45      } else if (sys === "Linux") {
46        await $.process.run(["notify-send", "--", title, body]);
47      } else {
48        await $.ui.toast(`${title}: ${body}`);
49      }
50    } catch {}
51    return next(e);
52  });
53}
54