shire index health in the status line, toasts when it changes, and a /shire pane with rebuild buttons

Shows shire's index health inside Claude Code. It reads shire status --json, which never rebuilds or writes.
shire ● 412 pkgs · 38.2k syms · 4m ago, with ⟳ while a build runs, ▲ for warnings (build failures, an interrupted build, a capped or partial file walk, pending re-checks, HEAD moved since the index was built) and ✗ when the index is missing or unreadable. Polled every 15 s and 3 s after any edit.serve --root) that changed nothing stays quiet./shire opens a pane with the details and Rebuild / Force rebuild buttons. Rebuild goes through the watch daemon when it is running and listening, otherwise runs shire build. /shire rebuild [--force] does the same from the prompt.A shire on PATH that has the status subcommand.
The files here are compiled into the shire binary (src/claude_mod.rs), so changing one changes what shire init --mod installs.
shire init --mod # or answer yes to the prompt in `shire init`
This writes the mod, compiled into the shire binary so it always matches its shire status, to ~/.claude/shire-mod/shire-status/ and lists that folder in env.CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json. shire install refreshes its files after an upgrade (without re-listing a folder you took out of the settings to switch it off), and shire uninstall removes it.
To run this folder straight from a checkout instead:
claude --plugin-dir /path/to/shire/contrib/claude-mod/shire-status
claude plugin validate contrib/claude-mod/shire-status
claude plugin test contrib/claude-mod/shire-status
Claude Code lays the API's type declarations into .claude-plugin/types/ when it loads the folder (git-ignored); after that tsc -p contrib/claude-mod/shire-status type-checks it. The mod API is early access and may change between Claude Code releases.
hooks/register.tsx 227 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { ShireStatus } from '../types'
5import { detailRows, settled, statusLine, transitions, warnings } from './format'
6
7const status = atom({ plugin: 'shire-status', key: 'status' } as const, null)
8const baseline = atom({ plugin: 'shire-status', key: 'baseline' } as const, null)
9const error = atom({ plugin: 'shire-status', key: 'error' } as const, null)
10const busy = atom({ plugin: 'shire-status', key: 'busy' } as const, null)
11
12const PANE = 'shire'
13/** Background poll; `shire status` is read-only and takes a few ms. */
14const POLL_MS = 15_000
15/** Re-poll this long after an edit, so a watch-daemon rebuild shows up. */
16const AFTER_EDIT_MS = 3_000
17const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
18/** A full build of a big monorepo runs for minutes. */
19const BUILD_TIMEOUT_MS = 600_000
20
21// Module variables: a reload starts them over, which is what they should do.
22let soon: Timer | undefined
23/** The poll running now, and the one queued behind it. */
24let current: Promise<void> = Promise.resolve()
25let queued: Promise<void> | null = null
26/** Set synchronously, so two quick presses cannot both start a build. */
27let rebuilding = false
28/** Tells this module load's `busy` entry from one a reloaded module left. */
29const OWNER = `${Date.now()}-${Math.random()}`
30
31/**
32 * Read `shire status`. Polls run one at a time, and a call made while one is
33 * running waits for a fresh poll after it (shared by every such call), so a
34 * caller never gets a snapshot taken before it asked.
35 */
36function poll($: EngineInterface): Promise<void> {
37 if (queued !== null) return queued
38 const run = current.then(() => {
39 queued = null
40 return pollOnce($)
41 })
42 queued = run
43 current = run
44 return run
45}
46
47async function pollOnce($: EngineInterface): Promise<void> {
48 let ran
49 try {
50 ran = await $.process.run(['shire', 'status', '--json'], { timeoutMs: 10_000 })
51 } catch (e) {
52 // Rejects when the command cannot start (no shire on PATH) or times out;
53 // the engine does not pass the cause through, so say both. A non-zero
54 // exit or output that is not JSON are reported separately below.
55 const msg = e instanceof Error ? e.message : String(e)
56 await fail($, `could not run shire status (is shire on PATH?): ${msg}`)
57 return
58 }
59 if (ran.exitCode !== 0) {
60 // An older shire has no `status` subcommand: clap exits 2.
61 const why = ran.stderr.includes('unrecognized subcommand')
62 ? 'this shire has no `status` command; upgrade shire'
63 : ran.stderr.trim().split('\n')[0] || `shire status exited ${ran.exitCode}`
64 await fail($, why)
65 return
66 }
67 let next: ShireStatus
68 try {
69 next = JSON.parse(ran.stdout) as ShireStatus
70 } catch {
71 const first = ran.stdout.trim().split('\n')[0]?.slice(0, 120) ?? ''
72 await fail($, `shire status printed something that is not JSON: ${first || '(nothing)'}`)
73 return
74 }
75 if (settled(next)) {
76 for (const line of transitions(await read($, baseline), next)) $.ui.toast(`shire: ${line}`)
77 await update($, baseline, () => next)
78 }
79 await update($, status, () => next)
80 await update($, error, () => null)
81 $.ui.status(statusLine(next, await $.clock.now()))
82}
83
84async function fail($: EngineInterface, why: string): Promise<void> {
85 const had = await read($, error)
86 await update($, error, () => why)
87 await update($, status, () => null)
88 $.ui.status('shire ✗ unavailable (/shire for details)')
89 if (had !== why) $.ui.toast(`shire: ${why}`)
90}
91
92/**
93 * `shire rebuild` when the watch daemon is up and listening (it debounces and
94 * builds), else a build. Not when it is merely running: `shire rebuild`
95 * exits 0 even when it cannot reach the socket, so a wedged daemon would make
96 * the button report a rebuild that never happened.
97 */
98async function rebuild($: EngineInterface, force: boolean): Promise<string> {
99 if (rebuilding) return 'shire: a rebuild is already running'
100 rebuilding = true
101 const s = await read($, status)
102 const root = s?.root
103 const rootArgs = root ? ['--root', root] : []
104 const viaDaemon = !force && s?.watch.running === true && s.watch.listening
105 const argv = viaDaemon
106 ? ['shire', 'rebuild', ...rootArgs]
107 : ['shire', 'build', ...rootArgs, ...(force ? ['--force'] : [])]
108 const label = force ? 'force rebuild' : viaDaemon ? 'rebuild (watch daemon)' : 'rebuild'
109 await update($, busy, () => ({ label, owner: OWNER }))
110 $.ui.status(`shire ⟳ ${label}…`)
111 try {
112 const ran = await $.process.run(argv, { timeoutMs: BUILD_TIMEOUT_MS })
113 const tail = (ran.stderr.trim().split('\n').pop() ?? '').slice(0, 200)
114 return ran.exitCode === 0
115 ? `shire: ${label} ${viaDaemon ? 'requested' : 'finished'}`
116 : `shire: ${label} failed (exit ${ran.exitCode})${tail ? `: ${tail}` : ''}`
117 } catch (e) {
118 return `shire: ${label} could not run: ${e instanceof Error ? e.message : String(e)}`
119 } finally {
120 rebuilding = false
121 await update($, busy, b => (b?.owner === OWNER ? null : b))
122 await poll($)
123 }
124}
125
126export const register: Register = on => {
127 on('session.start', async ($, e, next) => {
128 await $.command.register({
129 name: 'shire',
130 description: 'shire index status; `/shire rebuild` or `/shire rebuild --force` to rebuild',
131 argumentHint: '[rebuild [--force]]',
132 })
133 void poll($)
134 $.clock.every(POLL_MS, () => void poll($))
135 return next(e)
136 })
137
138 on('command.run', { command: 'shire' }, async ($, e) => {
139 const args = e.args.trim().split(/\s+/).filter(Boolean)
140 if (args[0] === 'rebuild') {
141 return { text: await rebuild($, args.includes('--force')) }
142 }
143 await poll($)
144 await $.ui.open({ id: PANE, title: 'shire' })
145 const s = await read($, status)
146 if (s === null) return { text: `shire: ${(await read($, error)) ?? 'no status yet'}` }
147 const w = warnings(s)
148 return {
149 text: `shire: ${s.state}` + (w.length > 0 ? ` (${w.join(', ')})` : '') + ' — details in the pane.',
150 }
151 })
152
153 // An edit may trigger a watch-daemon rebuild; look again shortly after.
154 on('tool.call', async ($, e, next) => {
155 const ran = await next(e)
156 if (EDIT_TOOLS.has(String(e.tool))) {
157 soon?.cancel()
158 soon = $.clock.after(AFTER_EDIT_MS, () => void poll($))
159 }
160 return ran
161 })
162
163 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
164 const { Box, Text, Button } = $.ui.resolve(e)
165 const s = await read($, status)
166 const err = await read($, error)
167 const b = await read($, busy)
168 const running = b?.owner === OWNER ? b.label : null
169 const now = await $.clock.now()
170
171 if (s === null) {
172 return (
173 <Box flexDirection="column">
174 <Text color="red">{err ?? 'Reading shire status…'}</Text>
175 <Text dimColor>Install or update shire, then run /shire again.</Text>
176 </Box>
177 )
178 }
179
180 const label = Math.max(...detailRows(s, now).map(([k]) => k.length)) + 2
181 return (
182 <Box flexDirection="column">
183 {detailRows(s, now).map(([k, v]) => (
184 <Box key={k}>
185 <Text dimColor>{k.padEnd(label)}</Text>
186 <Text>{v}</Text>
187 </Box>
188 ))}
189 {s.last_build_failures.length > 0 && (
190 <Box flexDirection="column" marginTop={1}>
191 <Text color="yellow">Last build failures</Text>
192 {s.last_build_failures.slice(0, 10).map((f, i) => (
193 <Text key={`f${i}`} wrap="truncate-end">
194 [{f.kind}] {f.target}: {f.error}
195 </Text>
196 ))}
197 </Box>
198 )}
199 <Box marginTop={1}>
200 {running !== null ? (
201 <Text dimColor>{running}…</Text>
202 ) : (
203 <Box>
204 <Button
205 key="rebuild"
206 label="Rebuild"
207 hotkey="r"
208 variant="primary"
209 onPress={async () => $.ui.toast(await rebuild($, false))}
210 />
211 <Text> </Text>
212 <Button
213 key="force"
214 label="Force rebuild"
215 hotkey="f"
216 onPress={async () => $.ui.toast(await rebuild($, true))}
217 />
218 <Text> </Text>
219 <Button key="close" label="Close" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
220 </Box>
221 )}
222 </Box>
223 </Box>
224 )
225 })
226}
227hooks/format.ts 184 lines1import type { ShireStatus } from '../types'
2
3export type Health = 'ok' | 'busy' | 'warn' | 'bad'
4
5export function health(s: ShireStatus): Health {
6 switch (s.state) {
7 case 'missing':
8 case 'refused':
9 case 'unreadable':
10 return 'bad'
11 case 'building':
12 return 'busy'
13 case 'interrupted':
14 return 'warn'
15 case 'ok':
16 return warnings(s).length > 0 ? 'warn' : 'ok'
17 }
18}
19
20/** Short reasons the index is degraded, worst first. */
21export function warnings(s: ShireStatus): string[] {
22 const out: string[] = []
23 if (s.state === 'interrupted') out.push('last build interrupted')
24 const n = s.last_build_failures.length
25 if (n > 0) out.push(`${n} build failure${n === 1 ? '' : 's'}`)
26 if (s.file_walk === 'capped') out.push('file walk capped')
27 else if (s.file_walk === 'partial') out.push('unreadable paths')
28 if (s.pending_source_recheck.length > 0 && !s.build_running) {
29 out.push(`${s.pending_source_recheck.length} pending re-check`)
30 }
31 if (s.head_matches === false) out.push('HEAD moved')
32 return out
33}
34
35/** 38211 -> "38.2k"; null -> "?". */
36export function compact(n: number | null): string {
37 if (n === null) return '?'
38 if (n < 1000) return String(n)
39 // From 999_500 up, "k" would round to "1000k".
40 if (n < 999_500) return `${trim(n / 1000)}k`
41 return `${trim(n / 1_000_000)}M`
42}
43
44function trim(x: number): string {
45 return x >= 100 ? String(Math.round(x)) : x.toFixed(1).replace(/\.0$/, '')
46}
47
48/** Age of an RFC 3339 timestamp at `nowMs`: "12s", "4m", "3h", "2d". */
49export function age(iso: string | null, nowMs: number): string | null {
50 if (iso === null) return null
51 const t = Date.parse(iso)
52 if (Number.isNaN(t)) return null
53 const s = Math.max(0, Math.round((nowMs - t) / 1000))
54 if (s < 60) return `${s}s`
55 if (s < 3600) return `${Math.floor(s / 60)}m`
56 if (s < 86400) return `${Math.floor(s / 3600)}h`
57 return `${Math.floor(s / 86400)}d`
58}
59
60const GLYPH: Record<Health, string> = { ok: '●', busy: '⟳', warn: '▲', bad: '✗' }
61
62/** The one line pinned under the prompt. */
63export function statusLine(s: ShireStatus, nowMs: number): string {
64 const h = health(s)
65 const head = `shire ${GLYPH[h]}`
66 if (s.state === 'missing') return `${head} no index (run shire build)`
67 if (h === 'bad') return `${head} index ${s.state}`
68 const parts = [`${compact(s.counts.packages)} pkgs`, `${compact(s.counts.symbols)} syms`]
69 if (s.state === 'building') parts.unshift('building…')
70 else {
71 const a = age(s.indexed_at, nowMs)
72 if (a !== null) parts.push(`${a} ago`)
73 }
74 const [worst] = warnings(s)
75 if (worst !== undefined) parts.push(worst)
76 return `${head} ${parts.join(' · ')}`
77}
78
79/**
80 * Whether a snapshot can be compared against. One taken while a build runs
81 * may hold the last build's metadata or none at all (the build locks readers
82 * out), so toasts compare the last settled snapshot with the next settled
83 * one and skip the polls in between: otherwise a build a poll lands in loses
84 * its "index built" or "reindexed" toast.
85 */
86export function settled(s: ShireStatus): boolean {
87 return s.state !== 'building'
88}
89
90/**
91 * Toasts for what changed between two settled snapshots. Deliberately quiet:
92 * an on-demand rebuild (`serve --root`) rewrites `indexed_at` every few
93 * seconds while nothing changes, so a rebuild is only worth a toast when the
94 * counts moved, and build failures only when one is new.
95 */
96export function transitions(prev: ShireStatus | null, next: ShireStatus): string[] {
97 if (prev === null) return []
98 const out: string[] = []
99 if (prev.state === 'missing' && next.state === 'ok') {
100 out.push(`index built: ${compact(next.counts.symbols)} symbols`)
101 } else if (next.indexed_at !== prev.indexed_at && next.indexed_at !== null) {
102 const ds = delta(prev.counts.symbols, next.counts.symbols)
103 const df = delta(prev.counts.files, next.counts.files)
104 if (ds !== 0 || df !== 0) {
105 const took = next.build_duration_ms === null ? '' : ` in ${next.build_duration_ms}ms`
106 out.push(`reindexed${took}: ${signed(ds)} symbols, ${signed(df)} files`)
107 }
108 }
109 // A manifest that fails to parse fails again on every build, so compare
110 // what failed, not when the build ran.
111 const seen = new Set(prev.last_build_failures.map(failureKey))
112 const fresh = next.last_build_failures.filter(f => !seen.has(failureKey(f)))
113 const nf = next.last_build_failures.length
114 if (fresh[0] !== undefined) {
115 out.push(`build had ${nf} failure${nf === 1 ? '' : 's'}: ${fresh[0].target}`)
116 }
117 if (next.state === 'interrupted' && prev.state !== 'interrupted') {
118 out.push('a build was interrupted; the next build repairs the index')
119 }
120 if (next.file_walk === 'capped' && prev.file_walk !== 'capped') {
121 out.push('file walk hit the 500k cap: exclude directories in shire.toml')
122 }
123 if (prev.watch.running && !next.watch.running) out.push('watch daemon stopped')
124 return out
125}
126
127function failureKey(f: ShireStatus['last_build_failures'][number]): string {
128 return `${f.kind}\u0000${f.target}`
129}
130
131function delta(a: number | null, b: number | null): number {
132 return a === null || b === null ? 0 : b - a
133}
134
135function signed(n: number): string {
136 return n > 0 ? `+${n}` : String(n)
137}
138
139/** The rows the /shire pane shows, label then value. */
140export function detailRows(s: ShireStatus, nowMs: number): [string, string][] {
141 const rows: [string, string][] = [
142 ['state', s.state + (s.error ? ` (${s.error})` : '')],
143 ['root', s.root],
144 ['index', (s.db_path ?? '?') + (s.db_size_bytes === null ? '' : ` · ${bytes(s.db_size_bytes)}`)],
145 ]
146 if (s.indexed_at !== null) {
147 const took = s.build_duration_ms === null ? '' : ` · took ${s.build_duration_ms}ms`
148 rows.push(['indexed', `${age(s.indexed_at, nowMs) ?? '?'} ago${took}`])
149 }
150 if (s.git_commit !== null) {
151 const moved = s.head_matches === false ? ` (HEAD now ${short(s.head_commit)})` : ''
152 rows.push(['commit', short(s.git_commit) + moved])
153 }
154 const c = s.counts
155 rows.push([
156 'counts',
157 `${compact(c.packages)} packages · ${compact(c.symbols)} symbols · ` +
158 `${compact(c.references)} refs · ${compact(c.files)} files · ${compact(c.docs)} docs`,
159 ])
160 if (s.references_enabled !== null) {
161 rows.push(['references', s.references_enabled ? 'on' : 'off'])
162 }
163 if (s.file_walk !== null) rows.push(['file walk', s.file_walk])
164 if (s.pending_source_recheck.length > 0) {
165 rows.push(['pending', s.pending_source_recheck.join(', ')])
166 }
167 const w = s.watch
168 rows.push([
169 'watch',
170 w.running ? `running${w.pid === null ? '' : ` (pid ${w.pid})`}${w.listening ? '' : ', not listening'}` : 'not running',
171 ])
172 return rows
173}
174
175function short(sha: string | null): string {
176 return sha === null ? '?' : sha.slice(0, 8)
177}
178
179function bytes(n: number): string {
180 if (n < 1024) return `${n} B`
181 if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KiB`
182 return `${(n / 1024 / 1024).toFixed(1)} MiB`
183}
184types/index.d.ts 48 lines1/** One `shire status --json` object. */
2export type ShireStatus = {
3 shire_version: string
4 root: string
5 /** null when shire.toml could not be read (state is then `unreadable`). */
6 db_path: string | null
7 state: 'missing' | 'refused' | 'unreadable' | 'building' | 'interrupted' | 'ok'
8 error: string | null
9 db_size_bytes: number | null
10 build_running: boolean
11 indexed_at: string | null
12 build_duration_ms: number | null
13 git_commit: string | null
14 head_commit: string | null
15 head_matches: boolean | null
16 counts: {
17 packages: number | null
18 symbols: number | null
19 references: number | null
20 files: number | null
21 docs: number | null
22 }
23 references_enabled: boolean | null
24 file_walk: 'complete' | 'partial' | 'capped' | null
25 pending_source_recheck: string[]
26 last_build_failures: { kind: string; target: string; error: string }[]
27 watch: { running: boolean; listening: boolean; pid: number | null }
28}
29
30declare module 'claude-code' {
31 interface PluginState {
32 'shire-status': {
33 /** The last status read; null before the first poll or when it failed. */
34 status: ShireStatus | null
35 /** Why the last poll failed (shire not installed, an old shire, ...). */
36 error: string | null
37 /** The last snapshot taken while no build ran; toasts compare against it. */
38 baseline: ShireStatus | null
39 /**
40 * What a pane button is running right now, if anything. `owner` names
41 * the module load that started it: state survives a hot reload but the
42 * rebuild's `finally` may not, so another load's entry is stale.
43 */
44 busy: { label: string; owner: string } | null
45 }
46 }
47}
48