A control room above your prompt: background agents and shells, disk space and context at a glance. Two themes, loud and quiet. /stand toggles the list.

See disk space, context usage and blocked hooks above your Claude Code prompt.
<img alt="Leitstand above the Claude Code prompt: two jobs running with moving bars, one done, one failed, the disk and a footer that counts everything" src="docs/loud-light.gif" width="696">
Leitstand is a mod for Claude Code. It shows above your prompt what Claude Code does not: how much disk is left, how full the context is, and which hook just blocked a tool call. After long work it plays a sound once nothing runs any more. The name is German for control room.
claude plugin marketplace add dominikmartn/leitstand
claude plugin install leitstand@leitstand
Then start a new session. Leitstand stays out of sight until something needs you. Type /stand to open the list any time.
Leitstand needs a Claude Code version with mods (TypeScript plugin hooks). I tested it with Claude Code 2.1.288 on macOS.
Each row shows a state, a name, a bar and how much is used. Running agents and shells are not listed, because Claude Code already lists them below the prompt.
<img alt="Open list: two running jobs, one done, one failed with needs you, the disk at 79 percent" src="docs/loud-open-light.png" width="696">
compact, as a reminder to run /compact. Leitstand never compacts by itself.Type /stand to open or close the list. Closing it acknowledges the disk warning. The warning comes back when free space drops to the next level (20 GB).
<img alt="Nothing running, but the disk has only 28 GB free and the context is at 527k of 1M" src="docs/loud-warn-light.png" width="696">
When a hook of yours blocks a tool call, Leitstand shows the hook's name and its reason until your next prompt.
loud is the default. It has halftone bars, always lists the disk and has a footer that counts what needs you.
quiet has thin bars and no footer. The disk shows only when it runs low.
<img alt="Both themes side by side. Loud closed: one line with 2 running. Loud open: two running jobs, one done, one failed, the disk and a footer. Quiet closed: one line with 2 running. Quiet open: two running jobs with thin bars and the context row." src="docs/themes-light.png" width="912">
To use quiet, set the variable before you start Claude Code:
export LEITSTAND_THEME=quiet
| Variable | Default | What it does |
|---|---|---|
LEITSTAND_THEME | loud | loud or quiet |
LEITSTAND_DISK_HOST | unset | Measures the disk of an ssh host instead of this machine, for example a build server |
LEITSTAND_SOUND | on | off mutes the sound after long work |
The sound plays on macOS when work took longer than a minute and nothing runs any more. On Linux it stays silent.
The disk host goes to ssh -o BatchMode=yes, so it needs key-based login. Leitstand accepts a host name, an ssh alias, an IP address or user@host, for example buildbox, 10.0.0.5 or me@nas.local. Set ports and keys in ~/.ssh/config. A value with spaces, quotes or a leading dash shows invalid disk host, and Leitstand does not fall back to the local disk. When a measurement fails, the last value stays with stale next to it.
If a user asks you to install, configure or debug Leitstand, these facts apply.
hooks/register.tsx, listed in hooks/hooks.json. No MCP server, no skills. The only network call is ssh, and only when LEITSTAND_DISK_HOST is set.claude plugin marketplace add dominikmartn/leitstand, then claude plugin install leitstand@leitstand. Add --scope project to both for one project only. The user must start a new session.LEITSTAND_THEME = loud (default) or quiet. LEITSTAND_DISK_HOST = an ssh host, unset means this machine. LEITSTAND_SOUND = off to mute. Set them in the shell, or in ~/.claude/settings.json like this: { "env": { "LEITSTAND_THEME": "quiet" } }. A new session picks them up.Agent calls and Bash calls with run_in_background: true from the main session, only to know when nothing runs any more for the sound. A job ends with its task notification or a TaskStop.sh -c 'df -kP /System/Volumes/Data 2>/dev/null || df -kP /' every 5 minutes, or the same df through ssh when LEITSTAND_DISK_HOST is set. On macOS, afplay /System/Library/Sounds/Glass.aiff.claude plugin list shows leitstand@leitstand. In a new session, type /stand. A list with the disk row must appear above the prompt, and after Claude's first answer also a context row. /stand again closes it. If nothing appears, claude plugin validate <plugin dir> lists the hooks Leitstand registers.claude plugin test . in the repo.git clone https://github.com/dominikmartn/leitstand
cd leitstand
CLAUDE_CODE_PLUGIN_DIRS=$PWD claude
claude plugin test .
The editor types live in .claude-plugin/types/. Claude Code's plugin tooling generates them, and they are not committed.
I built this for myself first and called it Leitstand, the word for the room where people watch a power plant or a rail network. It stayed. English has kindergarten and zeitgeist, so it can take one more.
Made by @dominikmartn · MIT license
hooks/register.tsx 324 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Disk, Job } from '../types'
5import type { Level } from './disk'
6import { DF, diskTarget, level, needsYou, parseDf } from './disk'
7import { DOT, loudMeter, meter } from './bars'
8import { hookBlock } from './blocks'
9import { applyEndings, backgroundId, notifications, running, stopEnds } from './jobs'
10
11const disk = atom({ plugin: 'leitstand', key: 'disk' } as const, null)
12// Claude Code lists running jobs below the prompt itself; Leitstand only tracks them for the done sound
13const jobs = atom({ plugin: 'leitstand', key: 'jobs' } as const, [])
14const open = atom({ plugin: 'leitstand', key: 'open' } as const, null)
15const ctx = atom({ plugin: 'leitstand', key: 'ctx' } as const, null)
16const block = atom({ plugin: 'leitstand', key: 'block' } as const, null)
17const diskSeen = atom({ plugin: 'leitstand', key: 'diskSeen' } as const, 'ok')
18const early = atom({ plugin: 'leitstand', key: 'early' } as const, [])
19
20// Mid tones: readable on light and dark terminals
21const C = { clay: '#d97757', ok: '#4fa86b', warn: '#d08a1e', bad: '#d9534f', track: '#8a8580' }
22const MEASURE_EVERY = 5 * 60 * 1000
23const LONG_MS = 60 * 1000
24// From here on the context row stands above the prompt and says "compact"
25const CTX_SHOW = 250_000
26const W = { label: 24, bar: 14, right: 8 }
27
28type Theme = 'loud' | 'quiet'
29const theme = async ($: EngineInterface): Promise<Theme> => ((await $.env.get('LEITSTAND_THEME'))?.trim() === 'quiet' ? 'quiet' : 'loud')
30
31// Local disk by default; LEITSTAND_DISK_HOST measures another machine over ssh
32const target = async ($: EngineInterface) => diskTarget(await $.env.get('LEITSTAND_DISK_HOST'))
33const host = async ($: EngineInterface) => (await target($)).host
34const INVALID_HOST = 'LEITSTAND_DISK_HOST is not a plain host name'
35// One session measures, the others wait for its result
36const LEASE_MS = 20 * 1000
37const dfArgv = (hn: string | null) => (hn ? ['ssh', '-o', 'ConnectTimeout=5', '-o', 'BatchMode=yes', hn, DF] : ['sh', '-c', DF])
38const storeKey = (hn: string | null) => hn ?? 'local'
39
40async function measure($: EngineInterface): Promise<void> {
41 const at = await $.clock.now()
42 const { host: hn, invalid } = await target($)
43 let error: string
44 if (invalid) {
45 await update($, disk, () => ({ pct: null, freeGb: null, at, error: INVALID_HOST }))
46 return
47 }
48 // ponytail: the store has no compare-and-set, so two sessions can still both measure within the same instant; newer-wins below keeps the result right
49 await $.store.set(`disk-lease:${storeKey(hn)}`, at + LEASE_MS)
50 try {
51 const run = await $.process.run(dfArgv(hn), { timeoutMs: 15000 })
52 const parsed = run.exitCode === 0 ? parseDf(run.stdout) : null
53 if (parsed) {
54 const before = await read($, disk)
55 const next: Disk = { ...parsed, at, error: null }
56 await update($, disk, () => next)
57 const stored = (await $.store.get(`disk:${storeKey(hn)}`)) as Disk | undefined
58 if (!stored || stored.at <= at) await $.store.set(`disk:${storeKey(hn)}`, next)
59 if (level(parsed.freeGb) === 'bad' && level(before?.freeGb ?? null) !== 'bad') {
60 $.ui.toast(`${hn ?? 'Disk'} at ${parsed.pct} %, only ${parsed.freeGb} GB free`)
61 }
62 return
63 }
64 error = run.stderr.trim().split('\n').pop() || `df exited with ${run.exitCode}`
65 } catch (err) {
66 error = err instanceof Error ? err.message : String(err)
67 }
68 // Keep the last good value and its time, only note the error; the row marks it stale
69 await update($, disk, prev => ({ pct: prev?.pct ?? null, freeGb: prev?.freeGb ?? null, at: prev?.at ?? at, error }))
70}
71
72// All sessions share one measurement: only measure when the stored one is stale
73async function refreshDisk($: EngineInterface): Promise<void> {
74 const key = storeKey(await host($))
75 const seen = (await $.store.get(`disk-seen:${key}`)) as Level | undefined
76 if (seen) await update($, diskSeen, () => seen)
77 const shared = (await $.store.get(`disk:${key}`)) as Disk | undefined
78 const t = await $.clock.now()
79 if (shared && shared.pct !== null && t - shared.at < MEASURE_EVERY) {
80 await update($, disk, () => shared)
81 return
82 }
83 const lease = Number((await $.store.get(`disk-lease:${key}`)) ?? 0)
84 if (lease > t) return
85 await measure($)
86}
87
88// Done sound only after long work, so short answers stay silent. Played from the system, not shipped.
89const ding = async ($: EngineInterface) => {
90 if ((await $.env.get('LEITSTAND_SOUND'))?.trim() === 'off') return
91 void $.process.run(['afplay', '/System/Library/Sounds/Glass.aiff'], { timeoutMs: 5000 }).catch(() => {})
92}
93
94// A hook blocked a tool call: keep it until the next prompt
95async function noteBlock($: EngineInterface, ran: unknown): Promise<void> {
96 const r = ran as { deny?: string; isError?: boolean; text?: string }
97 const hit = r.deny !== undefined ? hookBlock(r.deny, true) : r.isError ? hookBlock(r.text ?? '', false) : null
98 if (!hit) return
99 await update($, block, () => hit)
100 $.ui.toast(`${hit.hook} blocked · ${hit.reason}`)
101}
102
103// What stands above the prompt without being asked: a low disk, a hook block, a full context
104async function alerts($: EngineInterface) {
105 const d = await read($, disk)
106 const lv = level(d?.freeGb ?? null)
107 const diskNeeds = needsYou(lv, await read($, diskSeen))
108 const g = await read($, block)
109 const c0 = await read($, ctx)
110 const c = c0 && typeof c0 === 'object' ? c0 : null
111 const ctxHigh = c !== null && c.tokens >= CTX_SHOW
112 return { d, lv, diskNeeds, g, c, ctxHigh, any: diskNeeds || !!g || ctxHigh }
113}
114
115const HUMAN = new Set(['composer', 'bridge', 'sdk'])
116
117const fit = (t: string, w: number) => ([...t].length > w ? [...t].slice(0, w - 1).join('') + '…' : t.padEnd(w))
118const kTok = (n: number) => (n >= 1_000_000 ? `${Math.round(n / 100_000) / 10}M` : `${Math.round(n / 1000)}k`)
119
120export const register: Register = on => {
121 on('session.start', async ($, e, next) => {
122 await $.command.register({ name: 'stand', description: 'Toggle the list: disk, context and what needs you' })
123 void refreshDisk($)
124 if ((await target($)).invalid) $.ui.toast(INVALID_HOST)
125 $.clock.every(60 * 1000, () => void refreshDisk($))
126 return next(e)
127 })
128
129 on('tool.call', async ($, e, next) => {
130 const input = e as unknown as Record<string, unknown>
131 const isAgent = e.tool === 'Agent'
132 const isBgBash = e.tool === 'Bash' && input.run_in_background === true
133 if (e.agentId) return next(e)
134 if (e.tool === 'TaskStop') {
135 const ran = await next(e)
136 await noteBlock($, ran)
137 const id = String(input.task_id ?? input.shell_id ?? '')
138 if (id && stopEnds(ran as { deny?: string; isError?: boolean; text?: string })) {
139 const at = await $.clock.now()
140 await update($, jobs, list => applyEndings(list, [{ id, status: 'failed' }], at).list)
141 }
142 return ran
143 }
144 if (!isAgent && !isBgBash) {
145 const ran = await next(e)
146 await noteBlock($, ran)
147 return ran
148 }
149
150 const tmp = String(e.tool_use_id ?? Math.random())
151 const label = String(input.description ?? input.command ?? e.tool).trim()
152 const startedAt = await $.clock.now()
153 await update($, jobs, list => [...list, { id: tmp, kind: isAgent ? 'agent' : 'bash', label, startedAt, endedAt: null, status: 'running' } as Job])
154
155 let ran: Awaited<ReturnType<typeof next>>
156 try {
157 ran = await next(e)
158 } catch (err) {
159 // The start threw: it never ran, so it must not stay "running"
160 await update($, jobs, list => list.filter(j => j.id !== tmp))
161 throw err
162 }
163 await noteBlock($, ran)
164 const text = 'text' in ran && typeof ran.text === 'string' ? ran.text : ''
165 const bg = backgroundId(text)
166 // A start that a hook blocked or that failed at once never ran: no job
167 const neverStarted = 'deny' in ran || ('isError' in ran && ran.isError === true)
168 // Blocked, or already finished in the foreground: nothing left to show
169 await update($, jobs, list => (neverStarted || !bg ? list.filter(j => j.id !== tmp) : list.map(j => (j.id === tmp ? { ...j, id: bg } : j))))
170 // Its ending may have arrived first
171 const pending = bg ? (await read($, early)).filter(d => d.id === bg) : []
172 if (pending.length) {
173 const at = await $.clock.now()
174 await update($, jobs, list => applyEndings(list, pending, at).list)
175 await update($, early, list => list.filter(d => d.id !== bg))
176 }
177 return ran
178 })
179
180 // Sound only when it is your turn: nothing runs any more, and it took a while. Once per prompt.
181 let workStart = 0
182 on('turn.complete', async ($, e, next) => {
183 if (workStart && !running(await read($, jobs)).length && (await $.clock.now()) - workStart >= LONG_MS) {
184 void ding($)
185 workStart = 0
186 }
187 return next(e)
188 })
189
190 // Endings are read only from prompts the engine stamped as task notifications: text a user pastes cannot end a job.
191 // Your own next prompt means you saw what ended.
192 on('prompt.submit', async ($, e, next) => {
193 if (e.origin?.kind === 'task-notification') {
194 const at = await $.clock.now()
195 const { list, unmatched } = applyEndings(await read($, jobs), notifications(e.text), at)
196 await update($, jobs, () => list)
197 // Keep only a few: an ending for a job started before this session never finds its row
198 if (unmatched.length) await update($, early, prev => [...prev, ...unmatched].slice(-20))
199 }
200 if (!HUMAN.has(e.origin?.kind ?? 'composer')) return next(e)
201 workStart = await $.clock.now()
202 await update($, block, () => null)
203 await update($, jobs, running)
204 return next(e)
205 })
206
207 on('session.measure', async ($, e, next) => {
208 const tokens = e.context.tokens ?? null
209 const window = e.context.window
210 const pct = e.context.percent ?? (tokens !== null && window ? (tokens / window) * 100 : null)
211 await update($, ctx, () => (tokens === null ? null : { tokens, window, pct: Math.round(pct ?? 0) }))
212 return next(e)
213 })
214
215 on('command.run', { command: 'stand' }, async $ => {
216 // loud opens by default, but shows nothing until something needs you: then the first /stand opens
217 const wasOpen = (await read($, open)) ?? ((await theme($)) === 'loud' && (await alerts($)).any)
218 await update($, open, () => !wasOpen)
219 // Folding means seen: the disk warning goes quiet
220 if (wasOpen) {
221 const lv = level((await read($, disk))?.freeGb ?? null)
222 await update($, diskSeen, () => lv)
223 await $.store.set(`disk-seen:${storeKey(await host($))}`, lv)
224 }
225 else void measure($)
226 return {}
227 })
228
229 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
230 if (e.props.hasSurvey) return next(e)
231 const th = await theme($)
232 const explicit = await read($, open)
233 const isOpen = explicit ?? th === 'loud'
234 const hostName = await host($)
235 const { d, lv, diskNeeds, g, c, ctxHigh, any } = await alerts($)
236 if (!any && explicit !== true) return next(e)
237
238 const { Box, Text } = $.ui.resolve(e)
239 const loud = th === 'loud'
240 const diskColor = lv === 'bad' ? C.bad : lv === 'warn' ? C.warn : C.clay
241
242 // loud shows the disk whenever the list is up; quiet only once it runs low
243 const diskShown = !!d && (d.pct === null ? !!d.error : loud || lv !== 'ok')
244 const ctxShown = c !== null && (!loud || ctxHigh || explicit === true)
245 const diskName = hostName ? `${hostName} disk` : 'disk'
246
247 const Track = ({ s: str }: { s: string }) => <Text color={C.track} dimColor>{str}</Text>
248 const Hint = () => <Text dimColor>{' /stand'}</Text>
249 const Row = ({ mark, color, state, label, bar, right, bold, hint }: { mark: string; color: string; state: string; label: string; bar: unknown; right: string; bold?: boolean; hint?: boolean }) => (
250 <Box>
251 <Text color={color}>{mark} </Text>
252 <Text color={color} bold={bold}>{fit(state, 11)}</Text>
253 <Text>{fit(label, W.label)} </Text>
254 {bar as never}
255 <Text dimColor>{right.padStart(W.right)}</Text>
256 {hint ? <Hint /> : null}
257 </Box>
258 )
259 const empty = <Track s={(loud ? DOT : '─').repeat(W.bar)} />
260
261 // Seen means calm: no "needs you", just the numbers in the warning color
262 const diskRow = (hint = false) => !d || !diskShown ? null : d.pct === null
263 ? <Row mark=" " color={C.track} state="disk" label={d.error === INVALID_HOST ? 'invalid disk host' : hostName ? `${hostName} unreachable` : 'disk unreadable'} bar={empty} right="" />
264 : (() => {
265 const [body, rest] = (loud ? loudMeter : meter)(d.pct, W.bar)
266 const bar = [<Text color={diskColor}>{body}</Text>, <Track s={rest} />]
267 // Last measurement failed: the old value stays, marked as stale
268 const diskRight = d.error ? 'stale' : `${d.pct} %`
269 if (diskNeeds) return <Row mark="!" color={diskColor} state="needs you" label={`${diskName} · ${d.freeGb} GB free`} bar={bar} right={diskRight} bold hint={hint} />
270 return <Row mark=" " color={diskColor} state={loud ? '' : 'disk'} label={`${loud ? diskName : hostName ?? 'disk'} · ${d.freeGb} GB free`} bar={bar} right={diskRight} />
271 })()
272
273 const ctxRow = (hint = false) => !ctxShown || !c ? null : (() => {
274 const [body, rest] = (loud ? loudMeter : meter)(c.pct, W.bar)
275 const color = ctxHigh ? C.warn : C.track
276 return <Row mark=" " color={color} state={ctxHigh ? 'compact' : 'context'} label={`${kTok(c.tokens)} of ${kTok(c.window)}`} bar={[<Text color={color}>{body}</Text>, <Track s={rest} />]} right={`${c.pct} %`} hint={hint} />
277 })()
278
279 const idle = <Row mark="·" color={C.track} state="idle" label="nothing needs you" bar={empty} right="" />
280 const openRows = isOpen ? [diskRow(), ctxRow()].filter(Boolean) : []
281 const list = isOpen && (openRows.length ? openRows : idle)
282
283 const blockLine = (hint: boolean) => g && (
284 <Box>
285 <Text color={C.bad}>■ </Text>
286 <Text bold>{g.hook}</Text>
287 <Text dimColor>{' blocked'}</Text>
288 {g.reason ? <Text dimColor>{` · ${fit(g.reason, 60).trimEnd()}`}</Text> : null}
289 {hint ? <Hint /> : null}
290 </Box>
291 )
292
293 if (loud) {
294 // The footer counts what needs you, folded or open; without it /stand hangs on the last row
295 const hang = diskNeeds ? null : g ? 'block' : !isOpen && ctxHigh ? 'ctx' : null
296 return (
297 <Box flexDirection="column">
298 {list}
299 {!isOpen && ctxHigh && ctxRow(hang === 'ctx')}
300 {blockLine(hang === 'block')}
301 {diskNeeds && (
302 <Box>
303 <Text color={C.warn} bold>! 1 needs you</Text>
304 <Hint />
305 </Box>
306 )}
307 {!diskNeeds && !hang && <Text dimColor>/stand</Text>}
308 </Box>
309 )
310 }
311
312 // quiet: no footer, /stand hangs on the last row
313 const last = isOpen ? null : g ? 'block' : ctxHigh ? 'ctx' : 'disk'
314 return (
315 <Box flexDirection="column">
316 {list}
317 {diskNeeds && !isOpen && diskRow(last === 'disk')}
318 {ctxHigh && !isOpen && ctxRow(last === 'ctx')}
319 {blockLine(last === 'block')}
320 </Box>
321 )
322 })
323}
324hooks/disk.ts 44 lines1// Free space counts, not percent: 68 GB free at 85 % is no emergency
2export const WARN_GB = 35
3export const BAD_GB = 20
4
5export type Level = 'ok' | 'warn' | 'bad'
6
7// macOS keeps user data on its own volume; `/` there is the sealed system volume
8export const DF = 'df -kP /System/Volumes/Data 2>/dev/null || df -kP /'
9
10export type Target = { host: string | null; invalid: boolean }
11
12// LEITSTAND_DISK_HOST is handed to ssh as one argv entry; refuse anything that could read as an option.
13// Set but refused is an error to show, not a silent fallback to the local disk.
14export function diskTarget(raw: string | undefined): Target {
15 const t = raw?.trim()
16 if (!t) return { host: null, invalid: false }
17 return /^[A-Za-z0-9][A-Za-z0-9._@-]*$/.test(t) ? { host: t, invalid: false } : { host: null, invalid: true }
18}
19
20// `df -kP`: Filesystem 1024-blocks Used Available Capacity Mounted on (macOS without -P adds inode columns)
21export function parseDf(out: string): { pct: number; freeGb: number } | null {
22 const line = out.trim().split('\n').reverse().find(l => /\s\d+%\s/.test(l))
23 if (!line) return null
24 const cols = line.trim().split(/\s+/)
25 const i = cols.findIndex(c => /^\d+%$/.test(c))
26 if (i < 3) return null
27 const avail = Number(cols[i - 1])
28 const pct = Number(cols[i].slice(0, -1))
29 if (!Number.isFinite(avail) || !Number.isFinite(pct)) return null
30 return { pct, freeGb: Math.round(avail / 1024 / 1024) }
31}
32
33export function level(freeGb: number | null): Level {
34 if (freeGb === null) return 'ok'
35 return freeGb <= BAD_GB ? 'bad' : freeGb <= WARN_GB ? 'warn' : 'ok'
36}
37
38const RANK: Record<Level, number> = { ok: 0, warn: 1, bad: 2 }
39
40// A warning you have seen stays quiet until it gets worse
41export function needsYou(lv: Level, seen: Level): boolean {
42 return RANK[lv] > RANK[seen]
43}
44hooks/bars.ts 21 lines1// quiet: thin lines. Block glyphs fill the whole cell and merge across stacked rows.
2export function meter(pct: number, width: number): [string, string] {
3 const n = fill(pct, width)
4 return ['━'.repeat(n), '─'.repeat(width - n)]
5}
6
7// loud: shade blocks like the original look. They fill the whole cell, so on a tight line height stacked bars touch.
8export const DOT = '·'
9const FULL = '▓'
10const RAMP = ['░', '░', '▒', '▓']
11
12// Solid body that dissolves at its edge, then dotted track
13export function loudMeter(pct: number, width: number): [string, string] {
14 const n = fill(pct, width)
15 // A full bar is finished: no fading edge
16 const fade = n === width ? [] : [...RAMP].reverse().slice(0, Math.min(2, n))
17 return [FULL.repeat(n - fade.length) + fade.join(''), DOT.repeat(width - n)]
18}
19
20const fill = (pct: number, width: number) => Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
21hooks/blocks.ts 15 lines1export type Block = { hook: string; reason: string }
2
3const firstLine = (t: string) => t.trim().split('\n')[0].trim()
4
5// A command hook that exits 2 comes back as "PreToolUse:Bash hook error: [<command>]: <stderr>"
6export function hookBlock(text: string, denied: boolean): Block | null {
7 const m = text.match(/hook error: \[([^\]]*)\]:\s*([\s\S]*)/)
8 if (m) {
9 const script = m[1].trim().split(/\s+/).pop() ?? ''
10 const name = script.split('/').pop()?.replace(/\.[a-z]+$/i, '') || 'hook'
11 return { hook: name, reason: firstLine(m[2]) }
12 }
13 return denied && text.trim() ? { hook: 'hook', reason: firstLine(text) } : null
14}
15hooks/jobs.ts 49 lines1import type { Job } from '../types'
2
3// Background start: Agent reports "agentId: x", Bash "with ID: x"
4export function backgroundId(text: string): string | null {
5 const m = text.match(/agentId:\s*([A-Za-z0-9_-]+)/) ?? text.match(/running in background with ID:\s*([A-Za-z0-9_-]+)/)
6 return m ? m[1] : null
7}
8
9// Endings arrive as <task-notification> with task-id and status
10export function notifications(raw: string): { id: string; status: Job['status'] }[] {
11 const out: { id: string; status: Job['status'] }[] = []
12 for (const block of raw.split('<task-notification>').slice(1)) {
13 const id = block.match(/<task-id>([^<]+)<\/task-id>/)?.[1]
14 const st = block.match(/<status>([^<]+)<\/status>/)?.[1]
15 if (!id || !st) continue
16 out.push({ id: id.trim(), status: st.trim() === 'completed' ? 'done' : 'failed' })
17 }
18 return out
19}
20
21// A TaskStop sends no <task-notification>: stopped, or already gone, means the job is not running.
22// A hook that blocks the stop leaves it running.
23export function stopEnds(ran: { deny?: string; isError?: boolean; text?: string }): boolean {
24 if (ran.deny !== undefined) return false
25 return !ran.isError || /No task found|not running/i.test(ran.text ?? '')
26}
27
28export type Ending = { id: string; status: Job['status'] }
29
30// Ended jobs stay until seen: loud shows them, quiet hides them.
31// Endings for ids not in the list yet come back, so a notification that beats the id rewrite is not lost.
32export function applyEndings(list: Job[], endings: Ending[], at: number): { list: Job[]; unmatched: Ending[] } {
33 const ended = new Map(endings.map(d => [d.id, d.status]))
34 const known = new Set(list.map(j => j.id))
35 return {
36 list: list.map(j => {
37 const status = ended.get(j.id)
38 return status && j.status === 'running' ? { ...j, status, endedAt: at } : j
39 }),
40 unmatched: endings.filter(d => !known.has(d.id)),
41 }
42}
43
44export function applyNotifications(list: Job[], raw: string, at: number): Job[] {
45 return applyEndings(list, notifications(raw), at).list
46}
47
48export const running = (list: Job[]) => list.filter(j => j.status === 'running')
49types/index.d.ts 33 lines1export type Disk = {
2 pct: number | null
3 freeGb: number | null
4 // Time of the last good measurement
5 at: number
6 error: string | null
7}
8
9export type Job = {
10 id: string
11 kind: 'agent' | 'bash'
12 label: string
13 startedAt: number
14 endedAt: number | null
15 status: 'running' | 'done' | 'failed'
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 leitstand: {
21 disk: Disk | null
22 jobs: Job[]
23 // null: the theme decides (loud shows the list, quiet folds it)
24 open: boolean | null
25 diskSeen: 'ok' | 'warn' | 'bad'
26 ctx: { tokens: number; window: number; pct: number } | null
27 block: { hook: string; reason: string } | null
28 // Endings that arrived before their job had its background id
29 early: { id: string; status: 'running' | 'done' | 'failed' }[]
30 }
31 }
32}
33