Raises a desktop notification when Claude finishes a reply

Reusable Claude Code skills, and a few mods.
Copy (or symlink) a skill folder into a project's .claude/skills/ to use it.
implementskeleton-uibook-pagerShows Claude's finished replies in a side pane as fixed pages with short lines, so they are easier to read in a wide terminal. Each session keeps its own book, and a restart or /resume brings it back.
To load it in every session, add this to ~/.claude/settings.json (inside the "env" block if there already is one), then start a new session:
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/code/claude-skills/book-pager" }
/pager shows or hides the pane.p / n turn the page when the pane has focus. With the prompt empty, , and . turn it from the prompt.width setting, in terminal columns of text (default 76, 20 to 200). Change "Page width" in /config, or set it in ~/.claude/settings.json and restart: "pluginConfigs": { "book-pager": { "options": { "width": 90 } } }
deferSaves a question for later without sending it to Claude.
/defer <question>, or end a prompt with /defer. Both work while Claude is busy. The prompt form never reaches Claude, and the command form keeps the question out of the conversation./defer with no text opens a list. Press 1, 2, and so on, or click. The question goes into the prompt box and leaves the list./differ does the same as /defer./resume, and /clear starts an empty one. A list is saved in $.store under items:<session id>, and is removed when it becomes empty, when it has not changed for 30 days, or when more than 50 lists are saved (the one changed longest ago goes first).notifyRaises a desktop notification, "<session> is done", when Claude finishes a reply. The name is the /rename title, else the folder's name. It goes through Claude Code's notification channel: set "preferredNotifChannel": "ghostty" in ~/.claude/settings.json (or auto in Ghostty). Claude Code raises its own notification when it waits for an answer.
A Notification hook also hears this notification, with notification_type plugin_notification, so a voice hook can skip it.
Separate the folders with ::
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/code/claude-skills/book-pager:~/code/claude-skills/defer:~/code/claude-skills/notify" }
The mod API is early access. book-pager was built on Claude Code 2.1.289; defer and notify on 2.1.296. Setup steps, the design and what was tested are in the design note.
hooks/register.ts 40 lines1import type { EngineInterface, Register } from "claude-code";
2
3const TITLE = "Claude Code";
4
5// The name the voice notification says: the /rename title, else the folder's name.
6async function sessionName(
7 $: EngineInterface,
8 transcriptPath: string,
9 cwd: string,
10): Promise<string> {
11 const found = await $.process
12 .run(["grep", '"type":"custom-title"', transcriptPath])
13 .catch(() => undefined);
14 const last = found?.stdout.trim().split("\n").pop() ?? "";
15 try {
16 const title = (JSON.parse(last) as { customTitle?: unknown }).customTitle;
17 if (typeof title === "string" && title !== "") {
18 return title;
19 }
20 } catch {
21 // No /rename in this session.
22 }
23
24 return cwd.split("/").filter(Boolean).pop() ?? "Claude";
25}
26
27// A banner when Claude finishes, beside the voice from ~/.claude/hooks/say-notify.sh. Claude Code raises its own banner
28// when it waits for an answer, through the same `preferredNotifChannel`.
29export const register: Register = (on) => {
30 on("classic.Stop", async ($, e, next) => {
31 const result = await next(e);
32 if (e.agent_id === undefined) {
33 const name = await sessionName($, e.transcript_path, e.cwd);
34 await $.ui.notify(`${name} is done`, { title: TITLE }).catch(() => undefined);
35 }
36
37 return result;
38 });
39};
40