SLOPSHOPPER

long-task-notifier

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…

newguardtoaststatusprocesstimer
v0.2.0no licenseupdated 2026-10-08joshkerr/claude-mods/long-task-notifier
A shopper browsing a rack in a slop shop
README

claude-mods

Three small Claude Code mods (hooks-module plugins) by Josh Kerr. Each runs on Linux, macOS and Windows.

ModWhat it does
pkexec-guardRoutes 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-notifierA 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-statuslineA 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.

Install

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.

Develop

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.

Source 3 files
hooks/register.ts 133 lines
1import 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}
133
hooks/format.ts 25 lines
1/** 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}
25
hooks/notify.ts 50 lines
1export 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