A band above the prompt showing the herdr workspace and pane, the working directory, and the git branch with its dirty/ahead/behind state, on Linux, macOS and…

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.tsx 116 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Git, Herdr, Status } from '../types'
5import { herdrLabel, parseGitStatus, shortenPath } from './status'
6
7const status = atom({ plugin: 'herdr-statusline', key: 'status' } as const, null)
8
9const REFRESH_MS = 20_000
10const HERDR_TTL_MS = 60_000
11
12let herdrCache: { value: Herdr | null; at: number } | undefined
13
14const readGit = async ($: EngineInterface, cwd: string): Promise<Git | null> => {
15 try {
16 const r = await $.process.run(['git', 'status', '--porcelain=v2', '--branch'], { cwd, timeoutMs: 5000 })
17 return r.exitCode === 0 ? parseGitStatus(r.stdout) : null
18 } catch {
19 return null
20 }
21}
22
23// The herdr workspace label and pane id, from the env herdr gives its panes.
24const readHerdr = async ($: EngineInterface): Promise<Herdr | null> => {
25 const workspace = await $.env.get('HERDR_WORKSPACE_ID')
26 const pane = await $.env.get('HERDR_PANE_ID')
27 if (!workspace || !pane) return null
28 const bin = (await $.env.get('HERDR_BIN_PATH')) ?? 'herdr'
29 let label = workspace
30 try {
31 const r = await $.process.run([bin, 'workspace', 'list'], { timeoutMs: 5000 })
32 if (r.exitCode === 0) label = herdrLabel(r.stdout, workspace) ?? workspace
33 } catch {
34 // herdr not answering: the id is still a fine label
35 }
36 return { label, pane }
37}
38
39const refresh = async ($: EngineInterface): Promise<void> => {
40 const now = await $.clock.now()
41 if (herdrCache === undefined || now - herdrCache.at > HERDR_TTL_MS) {
42 herdrCache = { value: await readHerdr($), at: now }
43 }
44 const cwd = await $.session.cwd()
45 const next: Status = {
46 herdr: herdrCache.value,
47 // HOME on Linux and macOS; Windows keeps the home directory in USERPROFILE.
48 cwd: shortenPath(cwd, (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))),
49 git: await readGit($, cwd),
50 }
51 await update($, status, () => next)
52}
53
54export const register: Register = on => {
55 on('session.start', async ($, e, next) => {
56 // The band first: nothing below may keep it from drawing.
57 await refresh($)
58 $.clock.every(REFRESH_MS, () => void refresh($))
59 try {
60 // `/statusline` is a built-in, so the refresh command gets its own name.
61 await $.command.register({ name: 'herdr-status', description: 'Refresh the herdr status band now' })
62 } catch {
63 // no command, still a band
64 }
65 return next(e)
66 })
67
68 on('command.run', { command: 'herdr-status' }, async $ => {
69 await refresh($)
70 return { text: 'Status band refreshed.' }
71 })
72
73 // Git state mostly changes inside a turn: redraw at both ends of one.
74 on('turn.start', ($, e, next) => {
75 void refresh($)
76 return next(e)
77 })
78 on('turn.complete', ($, e, next) => {
79 void refresh($)
80 return next(e)
81 })
82
83 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
84 const s = await read($, status)
85 if (e.props.hasSurvey || s === null) return next(e)
86
87 const { Box, Text } = $.ui.resolve(e)
88 const parts: JSX.Element[] = []
89 const sep = (key: string) => <Text key={key} color="subtle"> │ </Text>
90
91 if (s.herdr !== null) {
92 parts.push(
93 <Text key="ws" color="claude" bold>▣ {s.herdr.label}</Text>,
94 <Text key="pane" color="subtle"> {s.herdr.pane}</Text>,
95 sep('sep1'),
96 )
97 }
98 parts.push(<Text key="cwd" color="text">{s.cwd}</Text>)
99 if (s.git !== null) {
100 const dirty = s.git.changed > 0
101 parts.push(
102 sep('sep2'),
103 <Text key="branch" color={dirty ? 'warning' : 'success'} bold>⎇ {s.git.branch}</Text>,
104 dirty
105 ? <Text key="changed" color="warning"> ✎ {s.git.changed}</Text>
106 : <Text key="clean" color="success"> ✓</Text>,
107 )
108 if (s.git.ahead > 0) parts.push(<Text key="ahead" color="subtle"> ↑{s.git.ahead}</Text>)
109 if (s.git.behind > 0) parts.push(<Text key="behind" color="subtle"> ↓{s.git.behind}</Text>)
110 }
111 if (e.props.isWorking) parts.push(<Text key="working" color="claude"> ●</Text>)
112
113 return <Box flexDirection="row">{parts}</Box>
114 })
115}
116hooks/status.ts 48 lines1import type { Git } from '../types'
2
3const isSep = (c: string | undefined): boolean => c === '/' || c === '\\'
4
5/** `~` for the home directory, and the last three path segments at most; `/` or `\` as the path uses. */
6export const shortenPath = (cwd: string, home: string | undefined): string => {
7 const sep = cwd.includes('\\') && !cwd.includes('/') ? '\\' : '/'
8 const fold = (s: string) => (sep === '\\' ? s.toLowerCase() : s)
9 let p = cwd
10 if (home) {
11 const h = home.replace(/[\\/]+$/, '')
12 if (fold(p) === fold(h)) p = '~'
13 else if (fold(p.slice(0, h.length)) === fold(h) && isSep(p[h.length])) p = `~${p.slice(h.length)}`
14 }
15 const parts = p.split(/[\\/]/)
16 return parts.length > 4 ? `…${sep}${parts.slice(-3).join(sep)}` : p
17}
18
19/** Reads `git status --porcelain=v2 --branch` output. */
20export const parseGitStatus = (text: string): Git => {
21 let branch = '?'
22 let ahead = 0
23 let behind = 0
24 let changed = 0
25 for (const line of text.split('\n')) {
26 if (line.startsWith('# branch.head ')) branch = line.slice('# branch.head '.length).trim()
27 else if (line.startsWith('# branch.ab ')) {
28 const m = /\+(\d+) -(\d+)/.exec(line)
29 if (m) {
30 ahead = Number(m[1])
31 behind = Number(m[2])
32 }
33 } else if (line.length > 0 && !line.startsWith('#')) changed += 1
34 }
35 if (branch === '(detached)') branch = 'detached'
36 return { branch, changed, ahead, behind }
37}
38
39/** The workspace's label from `herdr workspace list` JSON, when it is there. */
40export const herdrLabel = (json: string, workspaceId: string): string | undefined => {
41 try {
42 const parsed = JSON.parse(json) as { result?: { workspaces?: { workspace_id: string; label?: string }[] } }
43 return parsed.result?.workspaces?.find(w => w.workspace_id === workspaceId)?.label || undefined
44 } catch {
45 return undefined
46 }
47}
48types/index.d.ts 10 lines1export type Herdr = { label: string; pane: string }
2export type Git = { branch: string; changed: number; ahead: number; behind: number }
3export type Status = { herdr: Herdr | null; cwd: string; git: Git | null }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'herdr-statusline': { status: Status | null }
8 }
9}
10