SLOPSHOPPER

notify

Animated desktop notifications when a session starts, a turn finishes, a tool fails, the context fills up, or Claude asks for permission. Nothing extra to…

newguardcommandtoastprocess
★ 4v0.2.1MITupdated 2026-10-03XD3an/cc-plus/mods/notify
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · notify
› fix the failing auth test and add an audit log call ⏺ 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 › /notify ⎿ notify: Notifications are on. ⎿ notify: ⎿ notify: /notify show status ⎿ notify: /notify test [all] show a sample popup ⎿ notify: /notify mute [1h] mute for a while (30m, 2h, ...) ⎿ notify: /notify off | on turn notifications off / back on ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

cc-plus

A lean, curated set of custom skills, commands, hooks, and plugin config for Claude Code.

Resources

Official Documentation

The document skills (docx, pdf, pptx, xlsx) are not redistributed here: they are under Anthropic's proprietary license. Get them from the official anthropics/skills repo or the document-skills plugin.

Community Resources

Bundled Plugin Dependencies

cc-plus doesn't vendor these, but declares them as plugin dependencies in .claude-plugin/plugin.json, so installing cc-plus also resolves and enables them:

  • humanizer - removes AI-writing tells from text
  • impeccable - frontend design fluency: /impeccable polish, audit, critique, ...
  • notify - desktop notifications, from this marketplace

Their marketplaces must already be known to your Claude Code installation, otherwise install reports a dependency-unsatisfied error naming the command to run first:

/plugin marketplace add blader/humanizer
/plugin marketplace add pbakaus/impeccable

Structure

cc-plus/
├── .claude-plugin/             # Plugin metadata (plugin.json, marketplace.json)
├── commands/                   # Custom commands
├── examples/                   # Reference files (e.g. settings.json for manual installs)
├── mods/                       # Function-hook mods, each its own plugin in the marketplace
├── resources/                  # Reference material: example CLAUDE.md files, slash-commands, workflow guides
├── scripts/                    # Standalone utility scripts (model switching, litellm proxy)
├── skills/                     # Custom skills
├── .dockerignore
├── .env.example
├── .gitignore
├── .mcp.json
├── docker-compose.yml
├── Dockerfile
├── LICENCE
├── README.docker.md
└── README.md

Usage

Option 1: Install as Plugin

The easiest way to use this collection - install as a Claude Code plugin:

# Add this repo as a marketplace
/plugin marketplace add XD3an/cc-plus

# Install the plugin
/plugin install cc-plus@cc-plus

Or add directly to your ~/.claude/settings.json:

{
  "enabledPlugins": {
    "cc-plus@cc-plus": true
  }
}

Option 2: Manual Installation

If you prefer manual control over what's installed:

# Clone the repo
git clone https://github.com/<your-username>/cc-plus.git

# Copy commands
cp cc-plus/commands/**/*.md ~/.claude/commands/

# Copy skills
cp -r cc-plus/skills/* ~/.claude/skills/
Add Notifications

Desktop notifications come from the notify mod: install it with /plugin install notify@cc-plus, or load it with claude --plugin-dir path/to/cc-plus/mods/notify.

Configure MCPs

Copy desired MCP servers from .mcp.json to your ~/.claude.json.

Important: Replace any YOUR_*_HERE placeholders with your actual API keys.

Option 3: Use as Plugin Directory

claude --plugin-dir "path/to/cc-plus"

Run with Docker

For a containerized deployment (isolated Linux environment with Claude Code pre-installed), see README.docker.md for detailed instructions.

Mods

mods/ holds Claude Code mods: plugins written as TypeScript function hooks (hooks/hooks.json → { "modules": ["./register.ts"] }). Each mod is a separate plugin in this marketplace, so you can install, disable, or break one without touching the others.

ModWhat it does
notifyNotifications when a session starts, a long turn finishes, a tool fails, or Claude asks for permission. On Windows, an animated popup with a little Claude buddy (built-in WPF); on macOS osascript, on Linux notify-send. Nothing extra to install.
/plugin install notify@cc-plus

While developing a mod, load it straight from disk so every save hot-reloads it:

claude --plugin-dir ./mods/notify
claude plugin validate ./mods/notify
claude plugin test ./mods/notify

Mods are an early-access Claude Code surface and the API may change between releases.

Configuration

Copy .env.example to .env and configure your settings:

cp .env.example .env

For manual installs, start from examples/settings.json and adjust enabledPlugins/hooks to taste.

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

License

See LICENCE for details.

Source 1 files
hooks/register.ts 617 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3type Platform = 'windows' | 'macos' | 'linux'
4type Kind = 'start' | 'done' | 'fail' | 'permission' | 'error' | 'context'
5type Lang = 'en' | 'zh-TW' | 'zh-CN' | 'ja'
6
7type Note = {
8  kind: Kind
9  title: string
10  body: string
11  /** Small print under the body: turn length, tool count, branch. */
12  meta?: string
13}
14
15const LANGS: readonly Lang[] = ['en', 'zh-TW', 'zh-CN', 'ja']
16
17// Failures often come in bursts (a flaky command retried); one popup per window is enough.
18const FAILURE_COOLDOWN_MS = 10_000
19// `$.store` key: when notifications come back, in ms; absent when they are on.
20const MUTED_UNTIL = 'mutedUntil'
21const FOREVER = Number.MAX_SAFE_INTEGER
22// Context warnings: once at the configured level, once more here; a drop below
23// REARM_PERCENT (a /compact, a /clear) lets them fire again.
24const CONTEXT_CRITICAL_PERCENT = 90
25const REARM_PERCENT = 50
26
27const LOOK: Record<Kind, { emoji: string; macSound: string }> = {
28  start: { emoji: '✳️', macSound: 'Pop' },
29  done: { emoji: '✅', macSound: 'Glass' },
30  fail: { emoji: '💥', macSound: 'Basso' },
31  permission: { emoji: '🔐', macSound: 'Ping' },
32  error: { emoji: '⚠️', macSound: 'Basso' },
33  context: { emoji: '🧠', macSound: 'Funk' },
34}
35
36type Strings = {
37  lines: Record<Kind, readonly string[]>
38  checkItOut: string
39  onBranch: (branch: string) => string
40  failed: string
41  turnError: string
42  tools: (n: number) => string
43  contextBody: (percent: number) => string
44  allow: string
45  deny: string
46  deniedFromPopup: string
47  openFolder: string
48  description: string
49  usage: string
50  statusOn: string
51  statusMuted: (left: string) => string
52  statusOff: string
53  muted: (left: string) => string
54  off: string
55  on: string
56  language: (lang: string) => string
57  badLanguage: string
58  tested: string
59}
60
61const TEXT: Record<Lang, Strings> = {
62  en: {
63    lines: {
64      start: ['Claude is online', 'Ready to roll', 'Standing by'],
65      done: ['All done', 'Wrapped up', 'Mission complete', 'Come take a look'],
66      fail: ['hit a snag', 'tripped up', 'got stuck'],
67      permission: ['needs your OK', 'is waiting on you', 'wants permission'],
68      error: ['The turn stopped', 'Ran into an error'],
69      context: ['Context is filling up', 'Running out of room'],
70    },
71    checkItOut: 'Come check it out~',
72    onBranch: b => `on ${b}`,
73    failed: 'Something went wrong...',
74    turnError: 'The turn ended with an error.',
75    tools: n => `${n} tools`,
76    contextBody: p => `${p}% of the context window is used. Time to /compact or start fresh.`,
77    allow: 'Allow',
78    deny: 'Deny',
79    deniedFromPopup: 'The user denied this from the notification popup.',
80    openFolder: 'Open folder',
81    description: 'Desktop notifications: test, mute, off, on, lang',
82    usage: [
83      '/notify            show status',
84      '/notify test [all] show a sample popup',
85      '/notify mute [1h]  mute for a while (30m, 2h, ...)',
86      '/notify off | on   turn notifications off / back on',
87      '/notify lang <auto|en|zh-TW|zh-CN|ja>',
88    ].join('\n'),
89    statusOn: 'Notifications are on.',
90    statusMuted: left => `Muted for ${left} more.`,
91    statusOff: 'Notifications are off. /notify on brings them back.',
92    muted: left => `Muted for ${left}.`,
93    off: 'Notifications off, in every session, until /notify on.',
94    on: 'Notifications are back on.',
95    language: l => `Language set to ${l}.`,
96    badLanguage: 'Pick one of: auto, en, zh-TW, zh-CN, ja',
97    tested: 'Sent a sample notification.',
98  },
99  'zh-TW': {
100    lines: {
101      start: ['Claude 上線了', '準備開工', '就位,隨時可以開始'],
102      done: ['搞定了', '收工', '任務完成', '交差了,回來看看'],
103      fail: ['翻車了', '出包了', '卡住了'],
104      permission: ['等你點頭', '需要你的授權', 'Claude 在等你'],
105      error: ['這回合中斷了', '遇到錯誤停下來了'],
106      context: ['context 快滿了', '記憶快裝不下了'],
107    },
108    checkItOut: '回來看看吧~',
109    onBranch: b => `在 ${b} 分支`,
110    failed: '出了點問題...',
111    turnError: '這回合因為錯誤而結束了。',
112    tools: n => `${n} 個工具`,
113    contextBody: p => `context 已用 ${p}%,建議 /compact 或開新的 session。`,
114    allow: '允許',
115    deny: '拒絕',
116    deniedFromPopup: 'The user denied this from the notification popup.',
117    openFolder: '開啟資料夾',
118    description: '桌面通知:test、mute、off、on、lang',
119    usage: [
120      '/notify            顯示狀態',
121      '/notify test [all] 跳一張示範通知',
122      '/notify mute [1h]  暫時靜音(30m、2h⋯)',
123      '/notify off | on   關閉/重新開啟通知',
124      '/notify lang <auto|en|zh-TW|zh-CN|ja>',
125    ].join('\n'),
126    statusOn: '通知開啟中。',
127    statusMuted: left => `靜音中,還剩 ${left}。`,
128    statusOff: '通知已關閉。輸入 /notify on 重新開啟。',
129    muted: left => `已靜音 ${left}。`,
130    off: '通知已關閉(所有 session),直到 /notify on。',
131    on: '通知重新開啟了。',
132    language: l => `語言已設為 ${l}。`,
133    badLanguage: '請選擇:auto、en、zh-TW、zh-CN、ja',
134    tested: '已送出一則示範通知。',
135  },
136  'zh-CN': {
137    lines: {
138      start: ['Claude 上线了', '准备开工', '就位,随时可以开始'],
139      done: ['搞定了', '收工', '任务完成', '交差了,回来看看'],
140      fail: ['翻车了', '出问题了', '卡住了'],
141      permission: ['等你点头', '需要你的授权', 'Claude 在等你'],
142      error: ['这一轮中断了', '遇到错误停下来了'],
143      context: ['context 快满了', '快装不下了'],
144    },
145    checkItOut: '回来看看吧~',
146    onBranch: b => `在 ${b} 分支`,
147    failed: '出了点问题...',
148    turnError: '这一轮因为错误而结束了。',
149    tools: n => `${n} 个工具`,
150    contextBody: p => `context 已用 ${p}%,建议 /compact 或开新的会话。`,
151    allow: '允许',
152    deny: '拒绝',
153    deniedFromPopup: 'The user denied this from the notification popup.',
154    openFolder: '打开文件夹',
155    description: '桌面通知:test、mute、off、on、lang',
156    usage: [
157      '/notify            显示状态',
158      '/notify test [all] 弹一张示例通知',
159      '/notify mute [1h]  暂时静音(30m、2h⋯)',
160      '/notify off | on   关闭/重新开启通知',
161      '/notify lang <auto|en|zh-TW|zh-CN|ja>',
162    ].join('\n'),
163    statusOn: '通知已开启。',
164    statusMuted: left => `静音中,还剩 ${left}。`,
165    statusOff: '通知已关闭。输入 /notify on 重新开启。',
166    muted: left => `已静音 ${left}。`,
167    off: '通知已关闭(所有会话),直到 /notify on。',
168    on: '通知重新开启了。',
169    language: l => `语言已设为 ${l}。`,
170    badLanguage: '请选择:auto、en、zh-TW、zh-CN、ja',
171    tested: '已发送一则示例通知。',
172  },
173  ja: {
174    lines: {
175      start: ['Claude がオンラインに', '準備完了', 'いつでもどうぞ'],
176      done: ['できました', '完了です', 'ミッション完了', '見に来てください'],
177      fail: ['でつまずきました', 'で失敗しました', 'で止まりました'],
178      permission: ['許可を待っています', 'あなたの確認が必要です', 'Claude が待っています'],
179      error: ['ターンが中断されました', 'エラーで止まりました'],
180      context: ['コンテキストが残りわずか', 'そろそろ満杯です'],
181    },
182    checkItOut: '見に来てください~',
183    onBranch: b => `${b} ブランチ`,
184    failed: '問題が発生しました...',
185    turnError: 'ターンがエラーで終了しました。',
186    tools: n => `ツール ${n} 回`,
187    contextBody: p => `コンテキストを ${p}% 使用中。/compact か新しいセッションをどうぞ。`,
188    allow: '許可',
189    deny: '拒否',
190    deniedFromPopup: 'The user denied this from the notification popup.',
191    openFolder: 'フォルダを開く',
192    description: 'デスクトップ通知:test、mute、off、on、lang',
193    usage: [
194      '/notify            状態を表示',
195      '/notify test [all] サンプル通知を表示',
196      '/notify mute [1h]  しばらくミュート(30m、2h など)',
197      '/notify off | on   通知をオフ/オンにする',
198      '/notify lang <auto|en|zh-TW|zh-CN|ja>',
199    ].join('\n'),
200    statusOn: '通知はオンです。',
201    statusMuted: left => `ミュート中(残り ${left})。`,
202    statusOff: '通知はオフです。/notify on で再開します。',
203    muted: left => `${left} ミュートしました。`,
204    off: 'すべてのセッションで通知をオフにしました(/notify on まで)。',
205    on: '通知を再開しました。',
206    language: l => `言語を ${l} に設定しました。`,
207    badLanguage: 'auto、en、zh-TW、zh-CN、ja から選んでください',
208    tested: 'サンプル通知を送りました。',
209  },
210}
211
212// On Windows the popup is assets/popup.ps1, an animated WPF card. This loader
213// runs it as a script block, so the execution policy has no script file to
214// block, and goes as -EncodedCommand, so the command line strips no quotes.
215// Everything the popup shows travels as JSON in an environment variable: no
216// text the model wrote is ever parsed as script.
217const WINDOWS_LOADER = '. ([scriptblock]::Create([IO.File]::ReadAllText($env:CC_NOTIFY_SCRIPT)))'
218
219const MACOS_NOTIFY = [
220  '-e', 'on run argv',
221  '-e', 'display notification (item 2 of argv) with title (item 1 of argv) subtitle (item 3 of argv) sound name (item 4 of argv)',
222  '-e', 'end run',
223]
224
225const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
226
227/** What powershell.exe -EncodedCommand takes: the script as UTF-16LE, in base64. */
228function encodeCommand(script: string): string {
229  const bytes: number[] = []
230  for (let i = 0; i < script.length; i++) {
231    const unit = script.charCodeAt(i)
232    bytes.push(unit & 0xff, unit >> 8)
233  }
234  let out = ''
235  for (let i = 0; i < bytes.length; i += 3) {
236    const [a = 0, b = 0, c = 0] = [bytes[i], bytes[i + 1], bytes[i + 2]]
237    const n = (a << 16) | (b << 8) | c
238    out += BASE64[(n >> 18) & 63]! + BASE64[(n >> 12) & 63]!
239    out += i + 1 < bytes.length ? BASE64[(n >> 6) & 63]! : '='
240    out += i + 2 < bytes.length ? BASE64[n & 63]! : '='
241  }
242  return out
243}
244
245const WINDOWS_LOADER_ENCODED = encodeCommand(WINDOWS_LOADER)
246
247let platform: Promise<Platform> | undefined
248let language: Promise<Lang> | undefined
249let configuredLanguage = 'en'
250let contextWarning = 80
251let contextWarned = 0
252let permissionButtons = true
253let asking = false
254let lastAskAt = -Infinity
255let cwd = ''
256let project = 'Claude Code'
257let branch: string | undefined
258let toolsThisTurn = 0
259let lastFailureAt = -Infinity
260
261function clip(text: string, max = 120): string {
262  const flat = text.replace(/\s+/g, ' ').trim()
263  return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
264}
265
266function pick(lines: readonly string[]): string {
267  return lines[Math.floor(Math.random() * lines.length)] ?? lines[0] ?? ''
268}
269
270function duration(ms: number): string {
271  const s = Math.round(ms / 1000)
272  if (s < 60) return `${s}s`
273  const m = Math.floor(s / 60)
274  return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`
275}
276
277/** `30m`, `2h`, `1d`, or a bare number of minutes. */
278function parseDuration(text: string): number | undefined {
279  const match = /^(\d+(?:\.\d+)?)\s*(m|min|h|hr|d)?$/i.exec(text.trim())
280  if (!match) return undefined
281  const unit = (match[2] ?? 'm').toLowerCase()
282  const factor = unit === 'd' ? 86_400_000 : unit.startsWith('h') ? 3_600_000 : 60_000
283  return Number(match[1]) * factor
284}
285
286/** Maps a locale tag (`zh_TW.UTF-8`, `ja-JP`, `zh-Hant`) to a language we speak. */
287function toLang(tag: string | undefined): Lang | undefined {
288  const t = (tag ?? '').toLowerCase().replace('_', '-')
289  if (t.startsWith('zh')) return /tw|hk|mo|hant/.test(t) ? 'zh-TW' : 'zh-CN'
290  if (t.startsWith('ja')) return 'ja'
291  if (t.startsWith('en')) return 'en'
292  return undefined
293}
294
295/** The answer's first real line, without the Markdown around it. */
296function preview(answer: string, t: Strings): string {
297  const line = answer
298    .split('\n')
299    .map(l => l.replace(/^[#>*\-\s|`]+/, '').replace(/[*_`]/g, '').trim())
300    .find(l => l.length > 0)
301  return clip(line ?? t.checkItOut, 110)
302}
303
304function headline(kind: Kind, flavor: string): string {
305  return `${LOOK[kind].emoji} ${project} · ${flavor}`
306}
307
308function fileUri(path: string): string {
309  const slashed = path.replace(/\\/g, '/')
310  return `file:///${slashed.replace(/^\/+/, '')}`
311}
312
313function detect($: EngineInterface): Promise<Platform> {
314  platform ??= (async () => {
315    if ((await $.env.get('OS')) === 'Windows_NT') return 'windows'
316    const { stdout } = await $.process.run(['uname', '-s'])
317    return stdout.trim() === 'Darwin' ? 'macos' : 'linux'
318  })()
319  return platform
320}
321
322/** The configured language, or with `auto` the system's, English when unknown. */
323function resolveLang($: EngineInterface): Promise<Lang> {
324  language ??= (async () => {
325    const chosen = toLang(configuredLanguage)
326    if (chosen) return chosen
327    const fromEnv = toLang((await $.env.get('LC_ALL')) || (await $.env.get('LANG')))
328    if (fromEnv) return fromEnv
329    if ((await detect($)) === 'windows') {
330      try {
331        const { stdout } = await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', '(Get-UICulture).Name'])
332        return toLang(stdout.trim()) ?? 'en'
333      } catch {
334        return 'en'
335      }
336    }
337    return 'en'
338  })()
339  return language
340}
341
342async function strings($: EngineInterface): Promise<Strings> {
343  return TEXT[await resolveLang($)]
344}
345
346async function readBranch($: EngineInterface, dir: string): Promise<string | undefined> {
347  try {
348    const { exitCode, stdout } = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir, timeoutMs: 5_000 })
349    const name = stdout.trim()
350    return exitCode === 0 && name && name !== 'HEAD' ? name : undefined
351  } catch {
352    return undefined // no git, or not a repository
353  }
354}
355
356/** How much longer notifications stay muted, in ms; 0 when they are on. */
357async function mutedFor($: EngineInterface): Promise<number> {
358  const until = Number((await $.store.get(MUTED_UNTIL)) ?? 0)
359  return Math.max(0, until - (await $.clock.now()))
360}
361
362async function show($: EngineInterface, note: Note, ask?: { detail: string }): Promise<string> {
363  const os = await detect($)
364  const t = await strings($)
365  const icon = `${$.plugin.root}/assets/${note.kind === 'error' ? 'fail' : note.kind}.png`
366  const urgent = note.kind === 'permission' || note.kind === 'fail' || note.kind === 'error' || note.kind === 'context'
367
368  const ran =
369    os === 'windows'
370      ? await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-EncodedCommand', WINDOWS_LOADER_ENCODED], {
371          env: {
372            CC_NOTIFY_SCRIPT: `${$.plugin.root}/assets/popup.ps1`.replace(/\//g, '\\'),
373            CC_NOTIFY_JSON: JSON.stringify({
374              kind: note.kind,
375              title: note.title,
376              body: note.body,
377              meta: note.meta,
378              launch: cwd ? fileUri(cwd) : undefined,
379              openLabel: t.openFolder,
380              ask: ask && { detail: ask.detail, allow: t.allow, deny: t.deny },
381            }),
382          },
383          // A popup may wait its turn for a free spot on screen, and a
384          // permission popup waits on screen for an answer.
385          timeoutMs: note.kind === 'permission' ? 300_000 : 180_000,
386        })
387      : os === 'macos'
388        ? await $.process.run(['osascript', ...MACOS_NOTIFY, note.title, note.body, note.meta ?? '', LOOK[note.kind].macSound])
389        : await $.process.run([
390            'notify-send',
391            '--app-name=Claude Code',
392            `--icon=${icon}`,
393            `--urgency=${urgent ? 'critical' : 'normal'}`,
394            note.title,
395            note.meta ? `${note.body}\n${note.meta}` : note.body,
396          ])
397
398  if (ran.exitCode !== 0) throw new Error(clip(ran.stderr) || `exit ${ran.exitCode}`)
399  return ran.stdout
400}
401
402/** One line on what a tool is about to do: the command, the file, the URL. */
403function describeCall(tool: string, input: unknown): string {
404  const fields = (input ?? {}) as Record<string, unknown>
405  const main = ['command', 'file_path', 'notebook_path', 'url', 'pattern', 'path']
406    .map(key => fields[key])
407    .find((value): value is string => typeof value === 'string')
408  return clip(`${tool}: ${main ?? JSON.stringify(input ?? {})}`, 200)
409}
410
411/**
412 * Asks Allow / Deny on the Windows popup and resolves with the answer, or
413 * undefined when the person closed it, clicked through to the terminal, or let
414 * it time out: then the terminal asks as it always does.
415 */
416async function askPermission($: EngineInterface, tool: string, input: unknown): Promise<'allow' | 'deny' | undefined> {
417  if ((await detect($)) !== 'windows' || (await mutedFor($)) > 0) return undefined
418  const t = await strings($)
419  try {
420    const stdout = await show(
421      $,
422      { kind: 'permission', title: headline('permission', pick(t.lines.permission)), body: '' },
423      { detail: describeCall(tool, input) },
424    )
425    const answer = /^decision: (allow|deny)\s*$/m.exec(stdout)?.[1]
426    return answer === 'allow' || answer === 'deny' ? answer : undefined
427  } catch (error) {
428    $.ui.log(`permission popup failed: ${String(error)}`, { to: 'debug' })
429    return undefined
430  }
431}
432
433// Never hold up the session for a notification: fire it, and fall back to an
434// in-app toast when the desktop one cannot be shown. `/notify test` passes
435// `force` to show one even while muted.
436function notify($: EngineInterface, build: (t: Strings) => Note, force = false) {
437  void (async () => {
438    if (!force && (await mutedFor($)) > 0) return
439    const note = build(await strings($))
440    await show($, note).catch(error => {
441      $.ui.log(`desktop notification failed: ${String(error)}`, { to: 'debug' })
442      $.ui.toast(`${note.title}: ${note.body}`)
443    })
444  })().catch(error => $.ui.log(`notification skipped: ${String(error)}`, { to: 'debug' }))
445}
446
447/** Warns once at the configured fill and once more near the end of the window. */
448async function checkContext($: EngineInterface) {
449  if (contextWarning <= 0) return
450  const percent = await $.session.usage().then(
451    usage => usage.context.percent,
452    () => undefined, // no reading this turn
453  )
454  if (percent === undefined) return
455  if (percent < REARM_PERCENT) {
456    contextWarned = 0
457    return
458  }
459  const level = percent >= CONTEXT_CRITICAL_PERCENT ? 2 : percent >= contextWarning ? 1 : 0
460  if (level <= contextWarned) return
461  contextWarned = level
462  notify($, s => ({ kind: 'context', title: headline('context', pick(s.lines.context)), body: s.contextBody(Math.round(percent)) }))
463}
464
465async function runCommand($: EngineInterface, args: string): Promise<string> {
466  const t = await strings($)
467  const [verb = '', ...rest] = args.trim().split(/\s+/)
468  const arg = rest.join(' ')
469
470  switch (verb.toLowerCase()) {
471    case '':
472    case 'status': {
473      const left = await mutedFor($)
474      const state = left === 0 ? t.statusOn : left > FOREVER / 2 ? t.statusOff : t.statusMuted(duration(left))
475      return `${state}\n\n${t.usage}`
476    }
477    case 'test': {
478      const kinds: readonly Kind[] = arg === 'all' ? ['done', 'fail', 'permission', 'context', 'start'] : ['done']
479      for (const kind of kinds) {
480        notify(
481          $,
482          s => ({
483            kind,
484            title: headline(kind, kind === 'fail' ? `Bash ${pick(s.lines.fail)}` : pick(s.lines[kind])),
485            body: kind === 'fail' ? 'command not found: foo' : kind === 'context' ? s.contextBody(82) : s.checkItOut,
486            meta: `⏱ 1m 05s · 🔧 ${s.tools(3)}${branch ? ` · ⎇ ${branch}` : ''}`,
487          }),
488          true,
489        )
490      }
491      return t.tested
492    }
493    case 'mute': {
494      const ms = parseDuration(arg || '1h')
495      if (ms === undefined) return t.usage
496      await $.store.set(MUTED_UNTIL, (await $.clock.now()) + ms)
497      return t.muted(duration(ms))
498    }
499    case 'off':
500      await $.store.set(MUTED_UNTIL, FOREVER)
501      return t.off
502    case 'on':
503    case 'unmute':
504      await $.store.delete(MUTED_UNTIL)
505      return t.on
506    case 'lang':
507    case 'language': {
508      const wanted = arg === 'auto' ? 'auto' : LANGS.find(l => l.toLowerCase() === arg.toLowerCase())
509      if (!wanted) return t.badLanguage
510      // Saved as the plugin's own setting; the change reloads this module with it.
511      const { deny } = await $.config.set({ key: 'notify.language', value: wanted })
512      if (deny !== undefined) return deny
513      configuredLanguage = wanted
514      language = undefined
515      return (await strings($)).language(wanted)
516    }
517    default:
518      return t.usage
519  }
520}
521
522export const register: Register = (on, options) => {
523  configuredLanguage = typeof options.language === 'string' ? options.language : 'en'
524  contextWarning = typeof options.contextWarning === 'number' ? options.contextWarning : 80
525  permissionButtons = options.permissionButtons !== false
526
527  on('session.start', async ($, e, next) => {
528    const started = await next(e)
529    cwd = e.cwd
530    project = e.cwd.split(/[\\/]/).filter(Boolean).pop() ?? project
531    branch = await readBranch($, e.cwd)
532    const t = await strings($)
533    await $.command.register({
534      name: 'notify',
535      description: t.description,
536      argumentHint: '[test | mute 1h | off | on | lang <code>]',
537      immediate: true,
538    })
539    if (e.isInteractive) {
540      notify($, s => ({ kind: 'start', title: headline('start', pick(s.lines.start)), body: branch ? s.onBranch(branch) : e.cwd }))
541    }
542    return started
543  })
544
545  on('command.run', { command: 'notify' }, async ($, e) => ({ text: await runCommand($, e.args) }))
546
547  on('turn.start', ($, e, next) => {
548    toolsThisTurn = 0
549    return next(e)
550  })
551
552  on('tool.call', async ($, e, next) => {
553    toolsThisTurn += 1
554    const ran = await next(e)
555    if (ran.deny === undefined && ran.isError === true) {
556      const now = await $.clock.now()
557      if (now - lastFailureAt >= FAILURE_COOLDOWN_MS) {
558        lastFailureAt = now
559        notify($, s => ({
560          kind: 'fail',
561          title: headline('fail', `${e.tool} ${pick(s.lines.fail)}`),
562          body: clip(ran.text ?? s.failed),
563        }))
564      }
565    }
566    return ran
567  })
568
569  on('turn.complete', async ($, e, next) => {
570    const done = await next(e)
571    // Subagent turns end inside the main turn; only the main loop's end means "come back".
572    if (e.agentId === undefined && (e.reason === 'answer' || e.reason === 'error')) {
573      const tools = toolsThisTurn
574      notify($, s => {
575        const meta = [`⏱ ${duration(e.durationMs)}`, tools > 0 ? `🔧 ${s.tools(tools)}` : undefined, branch ? `⎇ ${branch}` : undefined]
576          .filter(Boolean)
577          .join(' · ')
578        return e.reason === 'answer'
579          ? { kind: 'done', title: headline('done', pick(s.lines.done)), body: preview(e.answer, s), meta }
580          : { kind: 'error', title: headline('error', pick(s.lines.error)), body: s.turnError, meta }
581      })
582      await checkContext($)
583    }
584    return done
585  })
586
587  // Answer a permission prompt from the popup. Waiting on the popup costs the
588  // hook none of its time budget: a $ call in flight does not count.
589  on('classic.PermissionRequest', async ($, e, next) => {
590    if (!permissionButtons) return next(e)
591    asking = true
592    try {
593      const answer = await askPermission($, e.tool_name, e.tool_input)
594      if (answer === 'allow') return { decision: { behavior: 'allow' } }
595      if (answer === 'deny') return { decision: { behavior: 'deny', message: (await strings($)).deniedFromPopup } }
596    } finally {
597      asking = false
598      lastAskAt = await $.clock.now()
599    }
600    return next(e)
601  })
602
603  // Permission prompts and idle reminders: the moments Claude is waiting on you.
604  on('classic.Notification', async ($, e, next) => {
605    const result = await next(e)
606    const isPermission = e.notification_type === 'permission_prompt'
607    // The popup already asked about this one, Allow / Deny and all.
608    if (isPermission && (asking || (await $.clock.now()) - lastAskAt < 5_000)) return result
609    notify($, s => ({
610      kind: isPermission ? 'permission' : 'start',
611      title: isPermission ? headline('permission', pick(s.lines.permission)) : headline('start', e.title ?? 'Claude Code'),
612      body: clip(e.message),
613    }))
614    return result
615  })
616}
617