Shows a live timer for long-running Bash commands and background tasks, then toasts, chimes and sends a desktop notification when they finish, on Linux, macOS…

Three small Claude Code mods (hooks-module plugins) by Josh Kerr. Each runs on Linux, macOS and Windows.
| Mod | What it does |
|---|---|
pkexec-guard | Routes sudo in Bash calls to a password prompt you can actually answer. Claude Code's Bash tool cannot answer sudo's password prompt, so a plain sudo hangs or fails. On a Linux desktop sudo becomes pkexec (a polkit dialog); on macOS it becomes osascript -e 'do shell script "…" with administrator privileges' (the system administrator dialog); on Windows it is left to Windows sudo, which raises a UAC prompt. A headless Linux box keeps plain sudo. sudo with flags (-n, -u, -E, …) is refused with a hint rather than guessed at. |
long-task-notifier | A live timer in the status line for Bash commands and background tasks that run longer than a threshold, then a toast, a chime and a desktop notification when they finish. Linux uses paplay and notify-send, macOS uses afplay and Notification Center, Windows uses a system sound and a toast. Threshold, chime and notification are user options. |
herdr-statusline | A band above the prompt showing the herdr workspace and pane you are in (when Claude Code runs inside herdr), the working directory, and the git branch with its dirty, ahead and behind state. |
In a terminal Claude Code session:
/plugin install pkexec-guard --marketplace joshkerr/claude-mods
/plugin install long-task-notifier --marketplace joshkerr/claude-mods
/plugin install herdr-statusline --marketplace joshkerr/claude-mods
Answer y to add the marketplace the first time, then pick the user scope. Or from a shell:
claude plugin marketplace add joshkerr/claude-mods
claude plugin install pkexec-guard@claude-mods --scope user
claude plugin install long-task-notifier@claude-mods --scope user
claude plugin install herdr-statusline@claude-mods --scope user
Later, claude plugin marketplace update claude-mods then claude plugin update <mod> pulls a new version.
Each mod is a folder with .claude-plugin/plugin.json, hooks/hooks.json and the hooks module under hooks/. To run them from this checkout instead of an install, point CLAUDE_CODE_PLUGIN_DIRS at the mod folders (:-separated on Linux and macOS, ; on Windows), or pass claude --plugin-dir <folder>.
claude plugin validate <mod> # manifest, hooks and what the engine would refuse
claude plugin test <mod> # the *.test.ts beside the mod
tsc -p <mod> # after the mod has loaded once, which writes its types
The .claude-plugin/types/ folder inside each mod is written by the engine when the mod loads and is not committed.
hooks/register.ts 133 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3import { exitCodeIn, fmt, label, outputFileOf } from './format'
4import { commands } from './notify'
5import type { Platform } from './notify'
6
7type Running = { description: string; startedAt: number; background: boolean }
8
9// The engine follows `$` only into functions declared in the hooks module itself,
10// so the OS probe lives here rather than in a shared file.
11let os: Platform | undefined
12
13/** The host OS: `OS=Windows_NT` on Windows, else what `uname -s` answers (Darwin, or Linux). */
14const platform = async ($: EngineInterface): Promise<Platform> => {
15 if (os !== undefined) return os
16 if ((await $.env.get('OS')) === 'Windows_NT') return (os = 'win32')
17 try {
18 const r = await $.process.run(['uname', '-s'], { timeoutMs: 3000 })
19 if (r.exitCode === 0 && r.stdout.trim() === 'Darwin') return (os = 'darwin')
20 } catch {
21 // no uname: call it Linux
22 }
23 return (os = 'linux')
24}
25
26const BACKGROUND_POLL_MS = 3000
27const BACKGROUND_GIVE_UP_MS = 2 * 60 * 60 * 1000
28
29// Module state: starts over on a reload, which is fine for an in-flight timer.
30let threshold = 30_000
31let sound = true
32let notify = true
33const running = new Map<string, Running>()
34let ticker: Timer | undefined
35let seq = 0
36
37const paint = async ($: EngineInterface): Promise<void> => {
38 const now = await $.clock.now()
39 const shown = [...running.values()]
40 .filter(r => now - r.startedAt >= threshold)
41 .sort((a, b) => a.startedAt - b.startedAt)
42 if (shown.length === 0) {
43 $.ui.status(undefined)
44 return
45 }
46 const first = shown[0]
47 if (first === undefined) return
48 const rest = shown.slice(1)
49 const mark = first.background ? '⇢' : '⏳'
50 const more = rest.length > 0 ? ` +${rest.length} more` : ''
51 $.ui.status(`${mark} ${first.description} ${fmt(now - first.startedAt)}${more}`)
52}
53
54const track = ($: EngineInterface, id: string, task: Running): void => {
55 running.set(id, task)
56 ticker ??= $.clock.every(1000, () => void paint($))
57}
58
59const untrack = ($: EngineInterface, id: string): void => {
60 running.delete(id)
61 if (running.size === 0) {
62 ticker?.cancel()
63 ticker = undefined
64 $.ui.status(undefined)
65 }
66}
67
68const announce = async ($: EngineInterface, description: string, took: number, failed: boolean): Promise<void> => {
69 const head = failed ? `✗ failed after ${fmt(took)}` : `✓ done in ${fmt(took)}`
70 $.ui.toast(`${head} — ${description}`, { timeoutMs: 10_000 })
71 if (!sound && !notify) return
72 const cmd = commands(await platform($), {
73 title: `Claude Code: ${failed ? 'command failed' : 'done'} after ${fmt(took)}`,
74 body: description,
75 failed,
76 })
77 if (sound) void $.process.run(cmd.sound, { timeoutMs: 10_000 }).catch(() => {})
78 if (notify) void $.process.run(cmd.notify, { timeoutMs: 10_000 }).catch(() => {})
79}
80
81// A backgrounded command answers at once, so watch the task's output file for
82// the "[exited with code N]" line the runner appends when the command ends.
83const watchBackground = ($: EngineInterface, id: string, description: string, startedAt: number, file: string): void => {
84 track($, id, { description, startedAt, background: true })
85 let poll: Timer | undefined
86 const stop = () => {
87 poll?.cancel()
88 untrack($, id)
89 }
90 poll = $.clock.every(BACKGROUND_POLL_MS, async () => {
91 let code: number | undefined
92 try {
93 code = exitCodeIn(await $.fs.read(file))
94 } catch {
95 return // not written yet
96 }
97 if (code === undefined) return
98 stop()
99 const took = (await $.clock.now()) - startedAt
100 if (took >= threshold) void announce($, description, took, code !== 0).catch(() => {})
101 })
102 $.clock.after(BACKGROUND_GIVE_UP_MS, stop)
103}
104
105export const register: Register = (on, options) => {
106 threshold = Math.max(1, Number(options.thresholdSeconds ?? 30)) * 1000
107 sound = options.sound !== false
108 notify = options.desktopNotification !== false
109
110 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
111 const id = `bash-${++seq}`
112 const description = label(e)
113 const startedAt = await $.clock.now()
114 track($, id, { description, startedAt, background: false })
115 try {
116 const ran = await next(e)
117 if (ran.deny !== undefined) return ran
118
119 const result = ran.result as { backgroundTaskId?: string } | undefined
120 const file = result?.backgroundTaskId ? outputFileOf(ran.text) : undefined
121 if (file !== undefined) {
122 watchBackground($, `${id}-bg`, description, startedAt, file)
123 return ran
124 }
125 const took = (await $.clock.now()) - startedAt
126 if (took >= threshold) void announce($, description, took, ran.isError === true).catch(() => {})
127 return ran
128 } finally {
129 untrack($, id)
130 }
131 }).catch(($, e, next) => (next.called ? next(e) : next(e))) // never in the command's way
132}
133hooks/format.ts 25 lines1/** 42 → "42s", 102 → "1m 42s", 3725 → "1h 02m". */
2export const fmt = (ms: number): string => {
3 const s = Math.max(0, Math.round(ms / 1000))
4 if (s < 60) return `${s}s`
5 const m = Math.floor(s / 60)
6 if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
7 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
8}
9
10/** The Bash call's description when it has one, else the command, cut to one short line. */
11export const label = (e: { description?: string; command: string }, max = 60): string => {
12 const text = (e.description?.trim() || e.command).trim().replace(/\s+/g, ' ')
13 return text.length > max ? `${text.slice(0, max - 1)}…` : text
14}
15
16/** The output file a backgrounded Bash call names in its result text. */
17export const outputFileOf = (text: string | undefined): string | undefined =>
18 /Output is being written to: (\S+?)\.?(?:\s|$)/.exec(text ?? '')?.[1]
19
20/** The exit code the background task runner appends once the command ends. */
21export const exitCodeIn = (output: string): number | undefined => {
22 const m = /\[exited with code (\d+)\]/.exec(output)
23 return m ? Number(m[1]) : undefined
24}
25hooks/notify.ts 50 lines1export type Platform = 'linux' | 'darwin' | 'win32'
2
3export type Announcement = { title: string; body: string; failed: boolean }
4
5const LINUX_DONE = '/usr/share/sounds/freedesktop/stereo/complete.oga'
6const LINUX_FAIL = '/usr/share/sounds/freedesktop/stereo/dialog-warning.oga'
7const MAC_DONE = '/System/Library/Sounds/Glass.aiff'
8const MAC_FAIL = '/System/Library/Sounds/Basso.aiff'
9
10/** The inside of an AppleScript string literal. */
11const applescript = (s: string): string => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
12/** The inside of a PowerShell single-quoted literal. */
13const ps = (s: string): string => s.replace(/'/g, "''")
14
15// Windows PowerShell 5.1 reaches the toast API through its WinRT projection; the
16// PowerShell app id is one every Windows install already registers for toasts.
17const toastScript = (title: string, body: string): string =>
18 [
19 '[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null',
20 '$x = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)',
21 "$t = $x.GetElementsByTagName('text')",
22 `$t.Item(0).AppendChild($x.CreateTextNode('${ps(title)}')) | Out-Null`,
23 `$t.Item(1).AppendChild($x.CreateTextNode('${ps(body)}')) | Out-Null`,
24 "$id = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe'",
25 '[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($id).Show((New-Object Windows.UI.Notifications.ToastNotification -ArgumentList $x))',
26 ].join('; ')
27
28const POWERSHELL = ['powershell', '-NoProfile', '-NonInteractive', '-Command']
29
30/** The host's sound and notification commands, as argument vectors (no shell). */
31export const commands = (platform: Platform, a: Announcement): { sound: string[]; notify: string[] } => {
32 switch (platform) {
33 case 'darwin':
34 return {
35 sound: ['afplay', a.failed ? MAC_FAIL : MAC_DONE],
36 notify: ['osascript', '-e', `display notification "${applescript(a.body)}" with title "${applescript(a.title)}"`],
37 }
38 case 'win32':
39 return {
40 sound: [...POWERSHELL, `(New-Object Media.SoundPlayer "$env:WINDIR\\Media\\${a.failed ? 'chord' : 'notify'}.wav").PlaySync()`],
41 notify: [...POWERSHELL, toastScript(a.title, a.body)],
42 }
43 default:
44 return {
45 sound: ['paplay', a.failed ? LINUX_FAIL : LINUX_DONE],
46 notify: ['notify-send', '--app-name=Claude Code', `--icon=${a.failed ? 'dialog-warning' : 'utilities-terminal'}`, a.title, a.body],
47 }
48 }
49}
50