SLOPSHOPPER

leitstand

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.

newbandguardcommandtoastprompt
★ 12v0.2.2MITupdated 2026-10-08dominikmartn/leitstand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · leitstand
› 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 › /stand disk disk unreadable ·············· context 97k of 200k ▓▓▓▓▓▓▒······· 49 % /stand ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
disk disk unreadable ·············· context 97k of 200k ▓▓▓▓▓▓▒······· 49 % /stand
README

Leitstand

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.

Install

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.

What you see

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">

  • needs you: the disk is running low. The footer counts it in yellow.
  • disk: free space on this machine. The bar and the percent show how much is used. It warns at 35 GB free and turns red at 20 GB.
  • context: tokens used of the session's window. From 250k tokens on, the row turns yellow and says 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.

Two themes

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

Settings

VariableDefaultWhat it does
LEITSTAND_THEMEloudloud or quiet
LEITSTAND_DISK_HOSTunsetMeasures the disk of an ssh host instead of this machine, for example a build server
LEITSTAND_SOUNDonoff 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.

For agents

If a user asks you to install, configure or debug Leitstand, these facts apply.

  • Type: Claude Code plugin with a hooks module, 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.
  • Install: 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.
  • Requirements: Claude Code with mods (TypeScript plugin hooks). Tested with 2.1.288 on macOS. Not tested on Linux, where the sound stays off.
  • Configure: three environment variables, all optional. 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.
  • Jobs: not shown. Claude Code lists running agents and shells below the prompt itself. Leitstand still follows 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.
  • Commands it runs: 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.
  • State: the disk value is shared between sessions through the plugin store. All other state lives in the session.
  • Verify: 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.
  • Tests: claude plugin test . in the repo.

Develop

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.

Why German

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

Source 6 files
hooks/register.tsx 324 lines
1import { 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}
324
hooks/disk.ts 44 lines
1// 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}
44
hooks/bars.ts 21 lines
1// 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)))
21
hooks/blocks.ts 15 lines
1export 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}
15
hooks/jobs.ts 49 lines
1import 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')
49
types/index.d.ts 33 lines
1export 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