SLOPSHOPPER

desk-notify

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

newguardcommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · desk-notify
› fix the failing auth test and add an audit log call ● desk-notify: no notification command on this system; nothing is sent ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ 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 › /desk-notify ⎿ desk-notify: no notification command on this system; nothing is sent ⎿ desk-notify: ask on: a question waits for your answer ⎿ desk-notify: plan on: a plan waits for your approval ⎿ desk-notify: stop on: a turn ended or failed ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

desk-notify

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.

What it does

  1. When the model calls AskUserQuestion, it sends Question awaiting your answer, before the question starts waiting on you.
  2. When the model calls ExitPlanMode, it sends Plan awaiting your approval, before the approval starts waiting on you.
  3. When a main-loop turn ends (Stop), it sends Turn finished. A subagent's end is SubagentStop and sends nothing.
  4. When an API error ends a turn (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.
  5. Every notification carries the subtitle 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:

DesktopCommand
macOSosascript -e 'display notification ...'
Linuxnotify-send <title> <subtitle and body> (it has no subtitle field)
Windowsa 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.

Command

/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.

Install

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.

After installing

  1. Restart Claude Code.
  2. On macOS, if no notification shows up, check System Settings > Notifications for the app that osascript notifications appear under. On Linux, install notify-send (libnotify).
  3. Remove any hook of your own that already sends these notifications, or each event will notify twice.

What it can reach

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.

  1. Reads: the OS variable, the tool name of each call, and a failed turn's error text and last assistant message
  2. Runs: uname -s and two git rev-parse calls once per session; one osascript, notify-send or powershell.exe per notification
  3. Sends: a desktop notification with a fixed title, the project name and, for a failed turn, 60 characters of the error; nothing to the model and nothing off the machine
  4. Persists: in $.store, the on/off setting of each event
  5. Hostile input: the project name and the error text are escaped for the AppleScript and PowerShell string literals and pass to notify-send as one argv entry, so neither can run a command

Limits

  • The macOS notification has no icon of its own, because display notification takes no icon argument.
  • Whether a turn you interrupt raises Stop has not been measured.
  • The Linux and Windows commands have not been measured live.

Development

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

Source 2 files
hooks/register.ts 138 lines
1import 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}
138
hooks/notice.ts 130 lines
1/** 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