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

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.
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.
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.
e.agentId), aborted turns, and turns at or under threshold_seconds.uname -s: Darwin uses osascript, Linux uses notify-send, anything else (or a failed uname) uses $.ui.toast.$.session.cwd(). Body: the answer with whitespace collapsed, cut at 100 characters; an empty answer gives Finished in Ns.next(e); a failed notification command is swallowed.hooks: turn.complete
calls: $.process.run, $.session.cwd, $.ui.toast
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):
| Key | Default | Meaning |
|---|---|---|
threshold_seconds | 20 | Notify only when the turn took longer. Non-numeric falls back to 20. |
sound | empty | macOS sound name (Glass, Ping, ...). Empty is silent. |
only_when_unfocused (skip the notification while the terminal is focused) is out of scope: the mods API has no focus signal.sound and a custom threshold are not covered by tests.AbovePrompt band.None.
hooks/notify-on-finish.mjs 54 lines1// 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