Sends a desktop notification when a question or a plan waits for your answer, and when a turn ends or fails.

You leave a session working in one window and switch to another. Meanwhile the model asks you a question, waits for your plan approval, or finishes its turn, and nothing tells you. This mod sends a desktop notification at those moments, so a session never waits unseen.
AskUserQuestion, it sends Question awaiting your answer, before the question starts waiting on you.ExitPlanMode, it sends Plan awaiting your approval, before the approval starts waiting on you.Stop), it sends Turn finished. A subagent's end is SubagentStop and sends nothing.StopFailure), it sends Turn failed with the first line of the error, cut at 60 characters and without its markdown marks; if there is no error text, the turn's last words stand in.Claude Code and the project name: the primary repository (in a git worktree too), else the git root, else the session's directory. The name is read once at the session's start, so a shell cd does not rename it.The notification command returns at once and is killed after 5 seconds, so a hung notification daemon holds up no tool call:
| Desktop | Command |
|---|---|
| macOS | osascript -e 'display notification ...' |
| Linux | notify-send <title> <subtitle and body> (it has no subtitle field) |
| Windows | a PowerShell toast, which never waits on a click |
The desktop is read once per session: OS=Windows_NT means Windows, otherwise uname -s names Darwin or Linux. On any other system the mod says so once and sends nothing. A notification command that fails or is missing is reported once as a transcript line, until a different failure replaces it.
In the live check on 2.1.282, a question the model asked with AskUserQuestion ran osascript with exit 0 before the question showed up.
/desk-notify the desktop and each event's setting (also /desk-notify status) /desk-notify ask on | off a question that waits for your answer /desk-notify plan on | off a plan that waits for your approval /desk-notify stop on | off a turn that ended or failed
Each event is on by default and its setting is kept across sessions. There is no bare on or off: you turn events on and off one at a time.
claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install desk-notify@kilimcininkoroglu-mods
Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.
osascript notifications appear under. On Linux, install notify-send (libnotify).Validated with claude plugin validate on Claude Code 2.1.288:
❯ ./register.ts hooks: session.start, command.run{command=desk-notify}, tool.call{tool=/"^AskUserQuestion$"/}, tool.call{tool=/"^ExitPlanMode$"/}, classic.Stop, classic.StopFailure ❯ ./register.ts calls: $.clock.after (via notifyOn), $.command.register, $.env.get (via readPlatform), $.process.run (via gitOut, readPlatform, send), $.session.cwd (via readProject), $.store.get (via readSettings), $.store.set (via runCommand), $.ui.log ❯ ./register.ts env reads: OS
Reach L2: it runs processes.
display notification takes no icon argument.Stop has not been measured.make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test
hooks/register.ts 138 lines1import type { EngineInterface, Register } from 'claude-code'
2import { argvFor, askNotice, EVENTS, failNotice, planNotice, platformOf, projectNameOf, settingOf, statusText, stopNotice, USAGE, type Event, type Notice, type Platform } from './notice.ts'
3
4/** A notification command that runs longer than this is killed, so a hung daemon holds nothing up. */
5const NOTIFY_TIMEOUT_MS = 5_000
6
7/**
8 * The desktop found at the session's start, the project the notifications name, each event's setting,
9 * and the last failure reported, so a failure that repeats is written once.
10 */
11type State = { platform?: Platform; project: string; on: Record<Event, boolean>; lastError?: string }
12
13/** Writes a failure of the notification command once, until another failure replaces it. */
14function reportOnce($: EngineInterface, state: State, text: string): void {
15 if (state.lastError === text) return
16 state.lastError = text
17 $.ui.log(text)
18}
19
20/** Shows one notification without waiting on it; a command that fails or is missing says so once. */
21async function send($: EngineInterface, state: State, n: Notice): Promise<void> {
22 if (state.platform === undefined) return
23 const argv = argvFor(state.platform, n)
24 try {
25 const r = await $.process.run(argv, { timeoutMs: NOTIFY_TIMEOUT_MS })
26 if (r.exitCode === 0) state.lastError = undefined
27 else reportOnce($, state, `${argv[0]} exited ${r.exitCode}: ${r.stderr.trim()}`)
28 } catch (err) {
29 reportOnce($, state, `${argv[0]} did not run: ${err instanceof Error ? err.message : String(err)}`)
30 }
31}
32
33/**
34 * Sends the notification of one event while that event is on, as the store holds it now; the hook that
35 * calls it does not wait on the notification command. The command runs on a timer, not in the calling
36 * dispatch: since 2.1.288 a process call still in flight when the dispatch closes is aborted, and a
37 * turn's end notification would die that way.
38 */
39async function notifyOn($: EngineInterface, state: State, event: Event, n: Notice): Promise<void> {
40 if (state.platform === undefined) return
41 await readSettings($, state)
42 if (state.on[event]) $.clock.after(0, () => void send($, state, n))
43}
44
45/** The stdout of a git command, or '' where git fails or is missing. */
46async function gitOut($: EngineInterface, cwd: string, args: string[]): Promise<string> {
47 try {
48 const r = await $.process.run(['git', ...args], { cwd, timeoutMs: 3_000 })
49 return r.exitCode === 0 ? r.stdout.trim() : ''
50 } catch {
51 // git is missing: the next way of naming the project answers.
52 return ''
53 }
54}
55
56/** The project name, read once at the start, so a shell `cd` later does not rename it. */
57async function readProject($: EngineInterface): Promise<string> {
58 const cwd = await $.session.cwd()
59 const commonDir = await gitOut($, cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
60 const top = commonDir === '' ? '' : await gitOut($, cwd, ['rev-parse', '--show-toplevel'])
61 return projectNameOf(commonDir, top, cwd)
62}
63
64/** The desktop this session runs on; `uname` is asked only where the Windows variable is absent. */
65async function readPlatform($: EngineInterface): Promise<Platform | undefined> {
66 const osVar = await $.env.get('OS')
67 if (osVar === 'Windows_NT') return platformOf(osVar, undefined)
68 try {
69 const r = await $.process.run(['uname', '-s'], { timeoutMs: 3_000 })
70 return platformOf(osVar, r.exitCode === 0 ? r.stdout : undefined)
71 } catch {
72 return undefined
73 }
74}
75
76/**
77 * Reads each event's on/off setting from the store, which every window shares, so a change made in
78 * another window applies here at the next hook that acts on it.
79 */
80async function readSettings($: EngineInterface, state: State): Promise<void> {
81 for (const ev of EVENTS) state.on[ev] = (await $.store.get(ev)) !== false
82}
83
84async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
85 await readSettings($, state)
86 if (args.trim() === '' || args.trim() === 'status') return statusText(state.platform, state.on)
87 const setting = settingOf(args)
88 if (setting === undefined) return USAGE
89 state.on[setting.event] = setting.on
90 await $.store.set(setting.event, setting.on)
91 return statusText(state.platform, state.on)
92}
93
94export const register: Register = on => {
95 const state: State = { project: '', on: { ask: true, plan: true, stop: true } }
96
97 on('session.start', async ($, e, next) => {
98 const r = await next(e)
99 await readSettings($, state)
100 state.platform = await readPlatform($)
101 state.project = await readProject($)
102 await $.command.register({
103 name: 'desk-notify',
104 description: 'Desktop notifications for a question, a plan and a turn end: status, ask|plan|stop on|off (desk-notify)',
105 argumentHint: '[ask | plan | stop] [on | off]',
106 immediate: true,
107 })
108 if (state.platform === undefined) $.ui.log('no notification command on this system; nothing is sent')
109 return r
110 })
111
112 // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
113 on('command.run', { command: 'desk-notify' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
114
115 // The tool call runs before its question or approval blocks on the person, which is when the notice helps.
116 // RegExp literals, because a headless /plugin-types lists neither tool, so a string matcher does not type.
117 on('tool.call', { tool: /^AskUserQuestion$/ }, async ($, e, next) => {
118 await notifyOn($, state, 'ask', askNotice(state.project))
119 return next(e)
120 })
121
122 on('tool.call', { tool: /^ExitPlanMode$/ }, async ($, e, next) => {
123 await notifyOn($, state, 'plan', planNotice(state.project))
124 return next(e)
125 })
126
127 // The main loop's end; a subagent's end is SubagentStop, so it never lands here.
128 on('classic.Stop', async ($, e, next) => {
129 await notifyOn($, state, 'stop', stopNotice(state.project))
130 return next(e)
131 })
132
133 on('classic.StopFailure', async ($, e, next) => {
134 await notifyOn($, state, 'stop', failNotice(state.project, e.error_details ?? e.error, e.last_assistant_message))
135 return next(e)
136 })
137}
138hooks/notice.ts 130 lines1/** Which desktop notification goes out for which moment of a session, and the command that shows it. */
2
3/** The moments the mod can report; each is turned on and off on its own. */
4export const EVENTS = ['ask', 'plan', 'stop'] as const
5export type Event = (typeof EVENTS)[number]
6
7/** The desktop the notification is shown on; each has its own command. */
8export type Platform = 'darwin' | 'linux' | 'windows'
9
10/** One notification: a title, a body and a small line under the title. */
11export type Notice = { title: string; message: string; subtitle: string }
12
13/** A failed turn names the first line of its error, cut to this many characters. */
14export const SUMMARY_MAX_CHARS = 60
15
16const SUBTITLE = 'Claude Code'
17
18/** What each event is, as the status and the usage name it. */
19const EVENT_TEXT: Record<Event, string> = {
20 ask: 'a question waits for your answer',
21 plan: 'a plan waits for your approval',
22 stop: 'a turn ended or failed',
23}
24
25export const USAGE = 'expects nothing (the status), or ask, plan or stop followed by on or off'
26
27/**
28 * The platform `uname -s` names, with the Windows `OS` variable read first, because Windows has no uname.
29 * Undefined for a system the mod has no notification command for.
30 */
31export function platformOf(osVar: string | undefined, uname: string | undefined): Platform | undefined {
32 if (osVar === 'Windows_NT') return 'windows'
33 const name = uname?.trim()
34 if (name === 'Darwin') return 'darwin'
35 if (name === 'Linux') return 'linux'
36 return undefined
37}
38
39/** A string for an AppleScript double-quoted literal. */
40export function escapeApplescript(s: string): string {
41 return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
42}
43
44/** A string for a PowerShell single-quoted literal. */
45export function escapePowershell(s: string): string {
46 return s.replace(/'/g, "''")
47}
48
49function windowsScript(n: Notice): string {
50 const title = escapePowershell(n.title)
51 const body = escapePowershell(`${n.subtitle} - ${n.message}`)
52 return (
53 '[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] > $null; ' +
54 '$x = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02); ' +
55 "$t = $x.GetElementsByTagName('text'); " +
56 `$t.Item(0).AppendChild($x.CreateTextNode('${title}')) > $null; ` +
57 `$t.Item(1).AppendChild($x.CreateTextNode('${body}')) > $null; ` +
58 "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe')" +
59 '.Show([Windows.UI.Notifications.ToastNotification]::new($x))'
60 )
61}
62
63/**
64 * The command that shows one notification and returns at once: `osascript` on macOS, `notify-send` on
65 * Linux, which has no subtitle and takes it into the body, and a PowerShell toast on Windows, which never
66 * waits on a click.
67 */
68export function argvFor(platform: Platform, n: Notice): string[] {
69 if (platform === 'darwin') {
70 return ['osascript', '-e', `display notification "${escapeApplescript(n.message)}" with title "${escapeApplescript(n.title)}" subtitle "${escapeApplescript(n.subtitle)}"`]
71 }
72 if (platform === 'linux') return ['notify-send', n.title, `${n.subtitle}\n${n.message}`]
73 return ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', windowsScript(n)]
74}
75
76/** The first line of a text with its markdown marks taken off, cut to SUMMARY_MAX_CHARS; '' for none. */
77export function summarize(text: unknown): string {
78 if (typeof text !== 'string') return ''
79 for (const raw of text.split('\n')) {
80 const line = raw.trim().replace(/^[#*\->| ]+/, '').trim().split(/\s+/).join(' ')
81 if (line === '') continue
82 return line.length > SUMMARY_MAX_CHARS ? `${line.slice(0, SUMMARY_MAX_CHARS - 1)}…` : line
83 }
84 return ''
85}
86
87/** The notification of a question the model put to the person. */
88export function askNotice(project: string): Notice {
89 return { title: 'Question awaiting your answer', message: project, subtitle: SUBTITLE }
90}
91
92/** The notification of a plan put up for approval. */
93export function planNotice(project: string): Notice {
94 return { title: 'Plan awaiting your approval', message: project, subtitle: SUBTITLE }
95}
96
97/** The notification of a turn that ended with an answer. */
98export function stopNotice(project: string): Notice {
99 return { title: 'Turn finished', message: project, subtitle: SUBTITLE }
100}
101
102/** The notification of a turn an API error ended: the error's first line, else the turn's last words. */
103export function failNotice(project: string, error: unknown, lastMessage: unknown): Notice {
104 const summary = summarize(error) || summarize(lastMessage)
105 return { title: 'Turn failed', message: summary === '' ? project : `${project}: ${summary}`, subtitle: SUBTITLE }
106}
107
108/** The project name: the primary repository in a worktree too, else the git root, else the directory. */
109export function projectNameOf(commonDir: string, top: string, cwd: string): string {
110 const parts = commonDir.replace(/\/+$/, '').split('/')
111 const last = parts.at(-1)
112 if (last === '.git') return parts.at(-2) ?? cwd
113 if (parts.at(-2) === 'worktrees' && parts.at(-3) === '.git') return parts.at(-4) ?? cwd
114 return (top === '' ? cwd : top).replace(/\/+$/, '').split('/').at(-1) ?? cwd
115}
116
117/** The `/desk-notify` status: the platform and each event with its setting. */
118export function statusText(platform: Platform | undefined, on: Record<Event, boolean>): string {
119 const head = platform === undefined ? 'no notification command on this system; nothing is sent' : `notifications on ${platform}`
120 return [head, ...EVENTS.map(ev => `${ev} ${on[ev] ? 'on' : 'off'}: ${EVENT_TEXT[ev]}`)].join('\n')
121}
122
123/** The event and setting a `/desk-notify <event> on|off` argument names, or undefined when it names none. */
124export function settingOf(args: string): { event: Event; on: boolean } | undefined {
125 const [word = '', value = '', ...rest] = args.trim().split(/\s+/)
126 const event = EVENTS.find(ev => ev === word)
127 if (event === undefined || rest.length > 0 || (value !== 'on' && value !== 'off')) return undefined
128 return { event, on: value === 'on' }
129}
130