Checks each mod at the door and lists what it can reach, from Claude Code's own validator.

<img src="../../brand/pixel-icons/svg/bouncer.svg" width="64" height="64" alt="">
Checks every installed mod at the door. When a session starts, Bouncer runs Claude Code's own plugin validator on each plugin folder it can name and reads what the report says the mod can reach: files, programs, the network, the model, your prompts, tool calls. One line above the prompt when a mod is new to you or its report changed; a /bouncer pane with a row per mod. Facts from the validator, never a verdict.
bouncer: 1 new mod: switchboard (runs programs) [Show]
Bouncer
Checked 19 mods at 09:12. Facts from the validator, not a verdict; nothing leaves the machine.
switchboard 0.1.0 new loaded reads and writes files, runs programs, reads the conversation [details]
cloakroom 0.1.0 loaded reads files, can change tool calls, can hold your prompts
pager 0.1.0 loaded reaches the network, plays sound, reads environment variables
headway 0.1.0 loaded adds a tool for Claude
wardrobe 0.1.0 loaded reads settings, changes your settings
zen 0.3.0 nothing declared
/plugin install bouncer --marketplace modmyclaude/claude-code-mods
/bouncer ok clears the line. Either way the line is gone for good once you have looked, until a mod is new or changed again./bouncer opens the pane: one row per mod with its name, version, new or changed, loaded when the engine named it joining this session, and its reach in words. The warning colour marks reach worth a second look. details unfolds the validator's own lines for that mod. Two folders with one name (a copy under development beside the installed one) are two rows, each showing its folder./bouncer check runs the validator again now.What the words mean, each read off the validator's list of what the mod calls and hooks: reads files, reads and writes files, runs programs, runs tools (it can run Claude's own tools, Bash and file writes among them, through the normal permission path), reaches the network, calls MCP tools, calls the API as you (it can spend your login on API calls), calls the model, starts subagents, can type for you (it can start a turn or fill the prompt box), can change tool calls (it hooks tool calls with a hook that can refuse or rewrite them), can hold your prompts, changes the system prompt, adds a tool for Claude (a tool or a subagent type Claude can call), adds to the conversation, reads the conversation, messages other sessions, can stop a turn, plays sound, reads environment variables, sets environment variables, reads settings, changes your settings, writes your clipboard, and nothing declared. Every call that acts outside the mod's own state has a word; a call that only draws, keeps the mod's own state or reads where the session is has none.
Every setting has a default, so the notice at install about options not yet set is expected; change them in /config.
| Setting | Default | What it does |
|---|---|---|
claudeBin | claude | The command the validator runs through: a name on your PATH or an absolute path. |
claude plugin validate --json <plugin folder> once per plugin folder, at session start and on /bouncer check, through the binary the claudeBin setting names; nothing else. The validator reads the mod's manifest and source itself; Bouncer reads only its report.~/.claude/plugins/installed_plugins.json and ~/.claude/plugins/known_marketplaces.json (under CLAUDE_CONFIG_DIR when that is set), the .claude-plugin/marketplace.json of each marketplace that lives in a local folder, and each plugin folder's .claude-plugin/plugin.json for its name and version. Never a mod's source. The engine also names each mod as it joins the session, and those folders are checked too.It never blocks, unloads or changes a mod, and never claims a mod is safe: a clean row means the validator found no reach to report, not that the mod is harmless.
claude on the PATH (the desktop app's own binary is not), set claudeBin to its path, or every row says could not check.--plugin-dir folder in a session that does not name it) is not checked. A folder the registry lists is checked whether or not this session loaded it; loaded marks the ones the engine confirmed.new when a later check passes. A mod seen before keeps its last hash through a failed check.This mod was built from the Bouncer prompt.
More mods: the full catalogue.
hooks/register.tsx 361 lines1// Bouncer: checks every installed mod at the door. At session start it runs
2// Claude Code's own plugin validator on each plugin folder it can name (this
3// one, the ones the engine names as they join, and the ones the plugin registry
4// files list) and reads what each can reach: files, programs, the network, the
5// model, your prompts, tool calls. One line above the prompt when a mod is new
6// to you or its report changed; a /bouncer pane with a row per mod.
7//
8// Facts from the validator, never a verdict. It never blocks, unloads or
9// changes a mod, never reads a mod's source, never calls the network.
10
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, Register, RenderNode } from 'claude-code'
13
14import type { BouncerRow } from '../types'
15import { BOLT_COLUMNS, boltSprite } from './bolt-draw'
16import { bandWords, clean, directoryMarketplaces, doorText, hashOf, installedRoots, manifestOf, marketplaceRoots, movedFrom, parseReport, reachWords, seenOf } from './door'
17import type { SeenMod } from './door'
18
19const PANE = 'bouncer'
20const SEEN = 'seen'
21/** The most plugin folders one check runs the validator on. */
22export const MAX_MODS = 60
23
24const rows = atom({ plugin: 'bouncer', key: 'rows' } as const, [] as BouncerRow[])
25const news = atom({ plugin: 'bouncer', key: 'news' } as const, null as string | null)
26const open = atom({ plugin: 'bouncer', key: 'open' } as const, null as string | null)
27const checkedAt = atom({ plugin: 'bouncer', key: 'checkedAt' } as const, 0)
28const isChecking = atom({ plugin: 'bouncer', key: 'isChecking' } as const, false)
29
30/** Mods the engine named as they joined this session, by their folder. */
31const registered = new Map<string, { name: string; version?: string }>()
32/** The last check started, for a test or a caller to wait on. */
33let lastCheck: Promise<void> = Promise.resolve()
34export const whenChecked = (): Promise<void> => lastCheck
35
36/** The button pairs a safety hold draws; while a pair is beneath, the band is left to the hold alone. */
37const HOLD_PAIRS: readonly (readonly [string, string])[] = [
38 ['Proceed', 'Cancel'],
39 ['Allow once', 'Deny'],
40]
41
42function buttonLabels(node: unknown, found: Set<string> = new Set()): Set<string> {
43 if (Array.isArray(node)) {
44 for (const child of node) buttonLabels(child, found)
45 return found
46 }
47 if (typeof node !== 'object' || node === null) return found
48 const el = node as { type?: unknown; props?: Record<string, unknown>; children?: unknown }
49 const type = typeof el.type === 'string' ? el.type : (el.type as { name?: string } | undefined)?.name
50 if (type === 'Button' && typeof el.props?.label === 'string') found.add(el.props.label)
51 buttonLabels(el.props?.children, found)
52 buttonLabels(el.children, found)
53 return found
54}
55
56function holdsBeneath(node: unknown): boolean {
57 const labels = buttonLabels(node)
58 return HOLD_PAIRS.some(([yes, no]) => labels.has(yes) && labels.has(no))
59}
60
61async function safely($: EngineInterface, work: () => Promise<unknown>): Promise<void> {
62 try {
63 await work()
64 } catch (error) {
65 $.ui.log(`bouncer: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
66 }
67}
68
69/** ~/.claude, or CLAUDE_CONFIG_DIR, as Claude Code finds its own folder. */
70async function configDir($: EngineInterface): Promise<string | undefined> {
71 const config = await $.env.get('CLAUDE_CONFIG_DIR')
72 if (config) return config.replace(/[\\/]+$/, '')
73 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
74 return home ? `${home.replace(/[\\/]+$/, '')}/.claude` : undefined
75}
76
77async function readText($: EngineInterface, path: string): Promise<string | undefined> {
78 try {
79 const text = await $.fs.read(path)
80 return typeof text === 'string' ? text : undefined
81 } catch {
82 return undefined
83 }
84}
85
86async function readJson($: EngineInterface, path: string): Promise<unknown> {
87 const text = await readText($, path)
88 if (text === undefined) return undefined
89 try {
90 return JSON.parse(text)
91 } catch {
92 return undefined
93 }
94}
95
96const trimSlash = (path: string) => path.replace(/\/+$/, '')
97
98/** Every plugin folder to check: this one, the ones the engine named, and the registry's. */
99async function rootsToCheck($: EngineInterface): Promise<Map<string, { name?: string; version?: string; isLoaded: boolean }>> {
100 const roots = new Map<string, { name?: string; version?: string; isLoaded: boolean }>()
101 roots.set(trimSlash($.plugin.root), { name: $.plugin.name, isLoaded: true })
102 for (const [root, who] of registered) roots.set(trimSlash(root), { ...who, isLoaded: true })
103 const base = await configDir($)
104 if (base !== undefined) {
105 for (const root of installedRoots(await readJson($, `${base}/plugins/installed_plugins.json`))) {
106 if (!roots.has(trimSlash(root))) roots.set(trimSlash(root), { isLoaded: false })
107 }
108 for (const location of directoryMarketplaces(await readJson($, `${base}/plugins/known_marketplaces.json`))) {
109 for (const root of marketplaceRoots(await readJson($, `${location}/.claude-plugin/marketplace.json`), location)) {
110 if (!roots.has(trimSlash(root))) roots.set(trimSlash(root), { isLoaded: false })
111 }
112 }
113 }
114 return roots
115}
116
117/** One folder through the validator: its reach and hash, or why it could not be checked. `before`: the hash it was last seen with, if any. */
118async function checkOne($: EngineInterface, bin: string, root: string, name: string, version: string | undefined, isLoaded: boolean, before: string | undefined): Promise<BouncerRow> {
119 try {
120 const ran = await $.process.run([bin, 'plugin', 'validate', '--json', root], { timeoutMs: 30_000 })
121 if (ran.isStdoutTruncated) return { ok: false, name, version, root, isLoaded, reason: 'the report was too long to read' }
122 const parsed = parseReport(ran.stdout)
123 if (parsed === null) {
124 return { ok: false, name, version, root, isLoaded, reason: ran.exitCode === 0 ? 'the report could not be read' : `the validator exited with ${ran.exitCode}` }
125 }
126 const hash = hashOf(parsed.reach)
127 return { ok: true, name, version, root, isLoaded, reach: parsed.reach, hash, isNew: before === undefined, isChanged: before !== undefined && before !== hash }
128 } catch (error) {
129 // The engine names the call in the message ($.process.run: ...); the person needs the rest.
130 const message = (error instanceof Error ? error.message : String(error)).replace(/^(?:[\w-]+: )?\$\.process\.run: /, '')
131 return { ok: false, name, version, root, isLoaded, reason: clean(message, 120) }
132 }
133}
134
135/** New and changed first, then the ones that could not be checked, then by name. */
136function byConcern(a: BouncerRow, b: BouncerRow): number {
137 const rank = (r: BouncerRow) => (r.ok ? (r.isNew || r.isChanged ? 0 : 2) : 1)
138 return rank(a) - rank(b) || a.name.localeCompare(b.name)
139}
140
141/** The whole check: every folder through the validator, the band's line, the seen record. */
142async function check($: EngineInterface, bin: string): Promise<void> {
143 if (await read($, isChecking)) return
144 await update($, isChecking, () => true)
145 try {
146 const roots = [...(await rootsToCheck($)).entries()].slice(0, MAX_MODS)
147 // Seen by plugin folder, so a copy under development beside the installed one is its own row with
148 // its own hash. A folder that is new while a folder of the same name is gone (an update installed
149 // into a new one) is compared with the old folder's hash, and the old entry goes.
150 const seen = seenOf(await $.store.get(SEEN))
151 const present = new Set(roots.map(([root]) => root))
152 const nowSeen: Record<string, SeenMod> = { ...seen }
153 const next: BouncerRow[] = []
154 for (const [root, who] of roots) {
155 const manifest = who.name !== undefined ? who : manifestOf((await readText($, `${root}/.claude-plugin/plugin.json`)) ?? '')
156 const name = clean(manifest.name ?? root.split('/').pop() ?? 'mod', 60)
157 const moved = movedFrom(seen, name, root, present)
158 const before = seen[root]?.hash ?? (moved === undefined ? undefined : seen[moved]?.hash)
159 const row = await checkOne($, bin, root, name, manifest.version, who.isLoaded, before)
160 next.push(row)
161 if (row.ok) {
162 nowSeen[root] = { name: row.name, hash: row.hash }
163 if (moved !== undefined) delete nowSeen[moved]
164 }
165 }
166 next.sort(byConcern)
167 const now = await $.clock.now()
168 await update($, rows, () => next)
169 await update($, news, () => bandWords(next))
170 await update($, checkedAt, () => now)
171 await $.store.set(SEEN, nowSeen)
172 } finally {
173 await update($, isChecking, () => false)
174 }
175}
176
177async function openPane($: EngineInterface): Promise<boolean> {
178 const opened = await $.ui.open({ id: PANE, title: 'Bouncer', closeOnEscape: true })
179 // Looked at: the line has done its job for this session.
180 await update($, news, () => null)
181 return opened.isPlaced
182}
183
184/** The hour and minute of a time, for the pane's "checked at". */
185function clockWords(ms: number): string {
186 const d = new Date(ms)
187 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
188}
189
190export const register: Register = (on, options) => {
191 const settings = (options ?? {}) as Record<string, unknown>
192 const bin = typeof settings.claudeBin === 'string' && settings.claudeBin.trim() !== '' ? settings.claudeBin.trim() : 'claude'
193
194 // The engine names each mod as it joins the session: the surest list of what is loaded.
195 on('plugin.register', async ($, e, next) => {
196 registered.set(trimSlash(e.root), { name: e.name, ...(e.version === undefined ? {} : { version: e.version }) })
197 return next(e)
198 }).catch(($, e, next) => next(e))
199
200 on('session.start', async ($, e, next) => {
201 const started = await next(e)
202 await safely($, async () => {
203 await $.command.register({
204 name: 'bouncer',
205 description: 'What every installed mod can reach, from the validator: the pane, check again, ok',
206 argumentHint: '[check | ok]',
207 immediate: true,
208 })
209 })
210 // Not awaited: the validator runs once per mod, and the session need not wait for it.
211 lastCheck = safely($, () => check($, bin))
212 return started
213 })
214
215 // Every answer is {}: command text is read by the model, and nothing here is for it.
216 on('command.run', { command: 'bouncer' }, async ($, e) => {
217 const verb = e.args.trim().toLowerCase()
218 if (verb === '') {
219 if (!(await openPane($))) $.ui.log(doorText(await read($, rows)))
220 return {}
221 }
222 if (verb === 'check') {
223 await lastCheck // one in flight finishes first, so this one is not skipped
224 lastCheck = safely($, () => check($, bin))
225 await lastCheck
226 $.ui.toast(`bouncer: ${(await read($, rows)).length} mods checked.`)
227 return {}
228 }
229 if (verb === 'ok') {
230 await update($, news, () => null)
231 $.ui.toast('bouncer: noted. The line goes until a mod is new or changed again.')
232 return {}
233 }
234 $.ui.toast('/bouncer opens the pane; /bouncer check runs the validator again; /bouncer ok clears the line.')
235 return {}
236 }).catch($ => {
237 $.ui.toast('/bouncer could not finish.')
238 return {}
239 })
240
241 // The band: one line while a mod is new or changed, under other mods' lines, never over a hold.
242 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
243 if (e.props.hasSurvey) return next(e)
244 const line = await read($, news)
245 if (line === null) return next(e)
246 const below = await next(e).catch(() => null)
247 if (below !== null && holdsBeneath(below)) return below
248 const { Box, Text, Button } = $.ui.resolve(e)
249 return (
250 <Box key="bouncer-wrap" flexDirection="column">
251 {below}
252 <Box key="bouncer" flexDirection="row" gap={1}>
253 <Box key="bouncer-line" flexShrink={1}>
254 <Text color="warning" wrap="truncate-end">
255 {line}
256 </Text>
257 </Box>
258 <Button key="bouncer-show" label="Show" onPress={() => void openPane($)} />
259 </Box>
260 </Box>
261 )
262 })
263
264 // The pane: one row per mod, its reach in words, details on request.
265 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
266 const elements = $.ui.resolve(e)
267 const { Box, Button, Text } = elements
268 const list = await read($, rows)
269 const unfolded = await read($, open)
270 const at = await read($, checkedAt)
271 const checking = await read($, isChecking)
272 const columns = e.props.bodyColumns
273 const body: RenderNode[] = []
274 // Two folders with one name (a copy under development beside the installed one) are two rows: numbered keys, and each shows its folder.
275 const twins = new Map<string, number>()
276 for (const row of list) twins.set(row.name, (twins.get(row.name) ?? 0) + 1)
277 const numbered = new Map<string, number>()
278 for (const row of list) {
279 const tag = row.ok ? (row.isNew ? 'new' : row.isChanged ? 'changed' : '') : ''
280 const nth = (numbered.get(row.name) ?? 0) + 1
281 numbered.set(row.name, nth)
282 const isTwin = (twins.get(row.name) ?? 0) > 1
283 const key = isTwin ? `${row.name}-${nth}` : row.name
284 body.push(
285 <Box key={`row-${key}`} flexDirection="column">
286 <Box key="line" flexDirection="row" gap={1}>
287 <Box key="name" flexShrink={0}>
288 <Text bold={tag !== ''}>{row.name}</Text>
289 </Box>
290 {isTwin ? (
291 <Box key="folder" flexShrink={1} minWidth={12}>
292 <Text dimColor wrap="truncate-start">
293 {row.root}
294 </Text>
295 </Box>
296 ) : null}
297 {row.version ? (
298 <Box key="version" flexShrink={0}>
299 <Text dimColor>{row.version}</Text>
300 </Box>
301 ) : null}
302 {tag !== '' ? (
303 <Box key="tag" flexShrink={0}>
304 <Text color="warning">{tag}</Text>
305 </Box>
306 ) : null}
307 {row.isLoaded ? (
308 <Box key="loaded" flexShrink={0}>
309 <Text dimColor>loaded</Text>
310 </Box>
311 ) : null}
312 <Box key="reach" flexDirection="row" flexGrow={1} flexShrink={1} flexWrap="wrap">
313 {row.ok ? (
314 reachWords(row.reach).map((w, i) => (
315 <Text key={`w-${i}`} color={w.warn ? 'warning' : undefined}>
316 {i === 0 ? w.word : `, ${w.word}`}
317 </Text>
318 ))
319 ) : (
320 <Text color="warning" wrap="truncate-end">{`could not check: ${row.reason}`}</Text>
321 )}
322 </Box>
323 {row.ok ? (
324 <Button key={`details-${key}`} label={unfolded === row.root ? 'less' : 'details'} onPress={() => update($, open, was => (was === row.root ? null : row.root))} />
325 ) : null}
326 </Box>
327 {row.ok && unfolded === row.root ? (
328 <Box key="details" flexDirection="column" paddingLeft={2}>
329 {(row.reach.notes.length === 0 ? ['the validator reported no hooks and no calls'] : row.reach.notes).map((note, i) => (
330 <Text key={`note-${i}`} dimColor wrap="wrap">
331 {note}
332 </Text>
333 ))}
334 </Box>
335 ) : null}
336 </Box>,
337 )
338 }
339 return (
340 <Box key="bouncer-pane" flexDirection="column" paddingX={1}>
341 <Box key="title" flexDirection="row" gap={1} alignItems="center">
342 {columns >= BOLT_COLUMNS.tiny + 37 ? boltSprite(elements, e.surface, { size: 'tiny', pose: 'idle' }, 'title-bolt') : null}
343 <Text bold>Bouncer</Text>
344 </Box>
345 <Box key="sub">
346 <Text color="subtle" wrap="wrap">
347 {checking ? 'Checking the mods through the validator...' : at === 0 ? 'No check has run yet. /bouncer check runs one.' : `Checked ${list.length} mod${list.length === 1 ? '' : 's'} at ${clockWords(at)}. Facts from the validator, not a verdict; nothing leaves the machine.`}
348 </Text>
349 </Box>
350 <Box key="gap" height={1} />
351 {body}
352 <Box key="foot" marginTop={1}>
353 <Text color="subtle" wrap="wrap">
354 In the warning colour: runs programs, runs tools, reaches the network, calls MCP tools, calls the API as you, starts subagents, can type for you, can change tool calls, messages other sessions, changes your settings. Bouncer never blocks, unloads or changes a mod.
355 </Text>
356 </Box>
357 </Box>
358 )
359 })
360}
361hooks/bolt-draw.tsx 86 lines1// Draws Bolt, the modmyclaude mascot (brand/bolt-sprites), on the surface a hook draws for.
2// Same file in every mod that shows Bolt: mods install on their own, so they cannot share code.
3// Terminal: one Raster, a cell grid. Desktop: one code-font cell per character, since its Text is
4// proportional and a row of half-blocks would drift. Elsewhere: coloured Text runs.
5// Bolt is static here: no timer, no redraw of its own. Never draw it over a safety hold or in a toast.
6
7import type { BoxProps, ElementConstructor, RasterProps, RenderElement, TextProps } from 'claude-code'
8
9import { boltCells, boltRuns } from './bolt'
10import type { BoltCell, BoltSmallPose, BoltTinyPose } from './bolt'
11
12type Elements = {
13 Box: ElementConstructor<BoxProps>
14 Text: ElementConstructor<TextProps>
15 Raster?: ElementConstructor<RasterProps>
16}
17
18export type BoltPick = { size: 'small'; pose: BoltSmallPose } | { size: 'tiny'; pose: BoltTinyPose }
19
20/** Columns a pose takes: small is 10, tiny is 3. */
21export const BOLT_COLUMNS = { small: 10, tiny: 3 } as const
22
23/** The cells of a pose: half-blocks, two pixel rows per cell. */
24export function boltOf(pick: BoltPick): BoltCell[][] {
25 return pick.size === 'small' ? boltCells('small', pick.pose) : boltCells('tiny', pick.pose)
26}
27
28/** Bolt as one element under `key`, drawn the way `surface` lays out cells. */
29export function boltSprite(elements: Elements, surface: string, pick: BoltPick, key = 'bolt'): RenderElement {
30 const { Box, Text, Raster } = elements
31 const cells = boltOf(pick)
32 const columns = cells[0]?.length ?? 0
33 if (surface === 'terminal' && Raster !== undefined) {
34 return <Raster key={key} columns={columns} rows={cells.length} cells={rasterCells(cells)} />
35 }
36 const paint = (cell: BoltCell): TextProps => ({
37 ...(cell.fg !== undefined ? { color: cell.fg } : {}),
38 ...(cell.bg !== undefined ? { backgroundColor: cell.bg } : {}),
39 })
40 if (surface === 'desktop') {
41 return (
42 <Box key={key} flexDirection="column" flexShrink={0}>
43 {cells.map(line => (
44 <Box flexDirection="row" flexWrap="nowrap">
45 {line.map(cell =>
46 cell.fg === undefined && cell.bg === undefined ? <Box width={1} /> : <Box width={1}><Text {...paint(cell)}>{cell.ch}</Text></Box>,
47 )}
48 </Box>
49 ))}
50 </Box>
51 )
52 }
53 return (
54 <Box key={key} flexDirection="column" flexShrink={0}>
55 {cells.map(line => (
56 <Text>
57 {boltRuns(line).map(run => {
58 const text = run.ch.replace(/ /g, ' ')
59 return run.fg === undefined && run.bg === undefined ? text : <Text {...paint(run)}>{text}</Text>
60 })}
61 </Text>
62 ))}
63 </Box>
64 )
65}
66
67/** RasterProps.cells: standard base64 of little-endian u32 [codePoint, fg, bg] per cell, row-major. */
68export function rasterCells(cells: readonly (readonly BoltCell[])[]): string {
69 const DEFAULT = 0x01000000
70 const count = cells.reduce((sum, line) => sum + line.length, 0)
71 const view = new DataView(new ArrayBuffer(count * 12))
72 let at = 0
73 for (const line of cells) {
74 for (const cell of line) {
75 view.setUint32(at, cell.ch.codePointAt(0) ?? 32, true)
76 view.setUint32(at + 4, cell.fg === undefined ? DEFAULT : parseInt(cell.fg.slice(1), 16), true)
77 view.setUint32(at + 8, cell.bg === undefined ? DEFAULT : parseInt(cell.bg.slice(1), 16), true)
78 at += 12
79 }
80 }
81 let binary = ''
82 const bytes = new Uint8Array(view.buffer)
83 for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i] ?? 0)
84 return btoa(binary)
85}
86hooks/door.ts 259 lines1// Bouncer's pure parts: reading a validator report into what a mod can reach,
2// a hash of that reach, the plugin registry files, and the words the band and
3// the pane say. Nothing here touches the engine.
4
5import type { BouncerReach, BouncerRow } from '../types'
6
7const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g
8const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b[@-Z\\-_]/g
9
10/** Text made safe to draw: no escape sequences or control characters, one line, cut to `max`. */
11export function clean(text: string, max = 200): string {
12 const flat = text.replace(ANSI, '').replace(CONTROL, '�').replace(/\s+/g, ' ').trim()
13 const chars = [...flat]
14 return chars.length <= max ? flat : `${chars.slice(0, max - 1).join('')}…`
15}
16
17/** Splits a validator list on ", " outside braces: a matcher such as `tool.call{tool=Bash, agentId=x}` keeps its commas. */
18export function splitTop(list: string): string[] {
19 const out: string[] = []
20 let depth = 0
21 let cur = ''
22 for (let i = 0; i < list.length; i++) {
23 const ch = list[i]!
24 if (ch === '{') depth++
25 if (ch === '}') depth = Math.max(0, depth - 1)
26 if (depth === 0 && ch === ',' && list[i + 1] === ' ') {
27 out.push(cur.trim())
28 cur = ''
29 i++
30 continue
31 }
32 cur += ch
33 }
34 if (cur.trim() !== '') out.push(cur.trim())
35 return out
36}
37
38type Content = { notes?: unknown; gatingHooks?: unknown }
39
40/**
41 * A validator report (`claude plugin validate --json`) read into what the mod
42 * can reach; null when the text is not such a report.
43 */
44export function parseReport(text: string): { success: boolean; reach: BouncerReach } | null {
45 let json: unknown
46 try {
47 json = JSON.parse(text)
48 } catch {
49 return null
50 }
51 if (typeof json !== 'object' || json === null) return null
52 const report = json as { success?: unknown; contents?: unknown }
53 if (typeof report.success !== 'boolean' || !Array.isArray(report.contents)) return null
54 const notes: string[] = []
55 const gates: string[] = []
56 for (const content of report.contents as Content[]) {
57 if (Array.isArray(content.notes)) for (const n of content.notes) if (typeof n === 'string') notes.push(n)
58 if (Array.isArray(content.gatingHooks)) {
59 for (const g of content.gatingHooks) {
60 const hook = (g as { hook?: unknown } | null)?.hook
61 if (typeof hook === 'string') gates.push(hook)
62 }
63 }
64 }
65 const calls = [
66 ...new Set(
67 notes
68 .filter(n => / calls: /.test(n))
69 .flatMap(n => splitTop(n.split(' calls: ')[1] ?? ''))
70 .map(c => c.replace(/\s*\(via .*\)$/, '').trim())
71 .filter(c => c.startsWith('$.')),
72 ),
73 ].sort()
74 const hooks = [...new Set(notes.filter(n => / hooks: /.test(n)).flatMap(n => splitTop(n.split(' hooks: ')[1] ?? '')))].sort()
75 const has = (re: RegExp) => calls.some(c => re.test(c))
76 const hooksOn = (event: string) => hooks.some(h => h === event || h.startsWith(`${event}{`)) || gates.some(g => g === event || g.startsWith(`${event}{`))
77 const reach: BouncerReach = {
78 files: has(/^\$\.fs\.write/) ? 'read-write' : has(/^\$\.fs\.(read|list|stat|exists|ancestors)/) ? 'read' : 'none',
79 processes: has(/^\$\.process\.(run|spawn)/),
80 network: has(/^\$\.http\./),
81 model: has(/^\$\.model\./),
82 sound: has(/^\$\.audio\./),
83 env: has(/^\$\.env\.get/),
84 setsEnv: has(/^\$\.env\.set/),
85 settings: has(/^\$\.settings\.|^\$\.config\.list/),
86 changesSettings: has(/^\$\.config\.set/),
87 runsTools: has(/^\$\.tool\.call/),
88 mcp: has(/^\$\.mcp\./),
89 usesLogin: has(/^\$\.session\.authorize/),
90 startsAgents: has(/^\$\.agent\.spawn/),
91 addsTools: has(/^\$\.(tool|agent)\.register/),
92 addsToConversation: has(/^\$\.session\.append/),
93 readsConversation: has(/^\$\.session\.(messages|turns)/),
94 messagesSessions: has(/^\$\.session\.send/),
95 stopsTurns: has(/^\$\.turn\.abort/),
96 clipboard: has(/^\$\.ui\.copy/),
97 typesForYou: has(/^\$\.prompt\.(submit|fill)/),
98 changesToolCalls: hooksOn('tool.call'),
99 holdsPrompts: hooksOn('prompt.submit'),
100 changesPrompt: hooksOn('prompt.compose') || hooksOn('prompt.section') || hooksOn('prompt.context'),
101 hooks,
102 calls,
103 notes: notes.map(n => clean(n, 400)),
104 }
105 return { success: report.success, reach }
106}
107
108/** A short, stable hash of what a mod can reach (its calls and hooks): the band fires when it changes. */
109export function hashOf(reach: Pick<BouncerReach, 'calls' | 'hooks'>): string {
110 const text = `${reach.calls.join(',')}|${reach.hooks.join(',')}`
111 let h = 0x811c9dc5
112 for (let i = 0; i < text.length; i++) {
113 h ^= text.charCodeAt(i)
114 h = Math.imul(h, 0x01000193) >>> 0
115 }
116 return h.toString(16).padStart(8, '0')
117}
118
119/** The reach in words for a row; `warn` marks the ones worth a second look. */
120/** The warning colour marks the words a person should read twice: a way out of the session, or a hand on its controls. */
121export function reachWords(reach: BouncerReach): { word: string; warn: boolean }[] {
122 const out: { word: string; warn: boolean }[] = []
123 if (reach.files === 'read') out.push({ word: 'reads files', warn: false })
124 if (reach.files === 'read-write') out.push({ word: 'reads and writes files', warn: false })
125 if (reach.processes) out.push({ word: 'runs programs', warn: true })
126 if (reach.runsTools) out.push({ word: 'runs tools (commands, file writes)', warn: true })
127 if (reach.network) out.push({ word: 'reaches the network', warn: true })
128 if (reach.mcp) out.push({ word: 'calls MCP tools', warn: true })
129 if (reach.usesLogin) out.push({ word: 'calls the API as you', warn: true })
130 if (reach.model) out.push({ word: 'calls the model', warn: false })
131 if (reach.startsAgents) out.push({ word: 'starts subagents', warn: true })
132 if (reach.typesForYou) out.push({ word: 'can type for you', warn: true })
133 if (reach.changesToolCalls) out.push({ word: 'can change tool calls', warn: true })
134 if (reach.holdsPrompts) out.push({ word: 'can hold your prompts', warn: false })
135 if (reach.changesPrompt) out.push({ word: 'changes the system prompt', warn: false })
136 if (reach.addsTools) out.push({ word: 'adds a tool for Claude', warn: false })
137 if (reach.addsToConversation) out.push({ word: 'adds to the conversation', warn: false })
138 if (reach.readsConversation) out.push({ word: 'reads the conversation', warn: false })
139 if (reach.messagesSessions) out.push({ word: 'messages other sessions', warn: true })
140 if (reach.stopsTurns) out.push({ word: 'can stop a turn', warn: false })
141 if (reach.sound) out.push({ word: 'plays sound', warn: false })
142 if (reach.env) out.push({ word: 'reads environment variables', warn: false })
143 if (reach.setsEnv) out.push({ word: 'sets environment variables', warn: false })
144 if (reach.settings) out.push({ word: 'reads settings', warn: false })
145 if (reach.changesSettings) out.push({ word: 'changes your settings', warn: true })
146 if (reach.clipboard) out.push({ word: 'writes your clipboard', warn: false })
147 if (out.length === 0) out.push({ word: 'nothing declared', warn: false })
148 return out
149}
150
151const notable = (reach: BouncerReach): string => {
152 const words = reachWords(reach)
153 .filter(w => w.warn)
154 .map(w => w.word)
155 return words.length === 0 ? 'nothing notable' : words.join(', ')
156}
157
158/** The band's one line while a mod is new or changed, else null. */
159export function bandWords(rows: readonly BouncerRow[]): string | null {
160 const fresh = rows.filter((r): r is Extract<BouncerRow, { ok: true }> => r.ok && r.isNew)
161 const changed = rows.filter((r): r is Extract<BouncerRow, { ok: true }> => r.ok && r.isChanged)
162 if (fresh.length === 0 && changed.length === 0) return null
163 const parts: string[] = []
164 if (fresh.length > 0) {
165 parts.push(`${fresh.length} new mod${fresh.length === 1 ? '' : 's'}: ${fresh.map(r => `${r.name} (${notable(r.reach)})`).join('; ')}`)
166 }
167 if (changed.length > 0) {
168 parts.push(`${changed.length} changed: ${changed.map(r => `${r.name} (${notable(r.reach)})`).join('; ')}`)
169 }
170 return `bouncer: ${parts.join(' · ')}`
171}
172
173/** The install paths in `installed_plugins.json` (version 2): every scope, every plugin. */
174export function installedRoots(json: unknown): string[] {
175 if (typeof json !== 'object' || json === null) return []
176 const plugins = (json as { plugins?: unknown }).plugins
177 if (typeof plugins !== 'object' || plugins === null) return []
178 const roots: string[] = []
179 for (const entries of Object.values(plugins as Record<string, unknown>)) {
180 if (!Array.isArray(entries)) continue
181 for (const entry of entries) {
182 const path = (entry as { installPath?: unknown } | null)?.installPath
183 if (typeof path === 'string' && path.startsWith('/')) roots.push(path)
184 }
185 }
186 return roots
187}
188
189/** The directory marketplaces in `known_marketplaces.json`: their install locations, where a marketplace.json names its plugins. */
190export function directoryMarketplaces(json: unknown): string[] {
191 if (typeof json !== 'object' || json === null) return []
192 const out: string[] = []
193 for (const entry of Object.values(json as Record<string, unknown>)) {
194 const m = entry as { source?: { source?: unknown }; installLocation?: unknown } | null
195 if (m?.source?.source === 'directory' && typeof m.installLocation === 'string' && m.installLocation.startsWith('/')) out.push(m.installLocation.replace(/\/+$/, ''))
196 }
197 return out
198}
199
200/** The plugin folders a marketplace.json names by a relative `source`, under `base`. */
201export function marketplaceRoots(json: unknown, base: string): string[] {
202 if (typeof json !== 'object' || json === null) return []
203 const plugins = (json as { plugins?: unknown }).plugins
204 if (!Array.isArray(plugins)) return []
205 const out: string[] = []
206 for (const p of plugins) {
207 const source = (p as { source?: unknown } | null)?.source
208 if (typeof source === 'string' && source.startsWith('./') && !source.includes('..')) out.push(`${base}/${source.slice(2).replace(/\/+$/, '')}`)
209 }
210 return out
211}
212
213/** A plugin.json's name and version, when the text is one. */
214export function manifestOf(text: string): { name?: string; version?: string } {
215 try {
216 const m = JSON.parse(text) as { name?: unknown; version?: unknown }
217 return {
218 ...(typeof m.name === 'string' ? { name: clean(m.name, 60) } : {}),
219 ...(typeof m.version === 'string' ? { version: clean(m.version, 20) } : {}),
220 }
221 } catch {
222 return {}
223 }
224}
225
226/** The seen record as the store holds it: mod name to reach hash, anything else dropped. */
227/** What one plugin folder was last seen as: the mod's name there and the hash of its reach. */
228export type SeenMod = { name: string; hash: string }
229
230/** The seen record, by plugin folder; anything of another shape is dropped. */
231export function seenOf(stored: unknown): Record<string, SeenMod> {
232 if (typeof stored !== 'object' || stored === null || Array.isArray(stored)) return {}
233 const out: Record<string, SeenMod> = {}
234 for (const [root, value] of Object.entries(stored as Record<string, unknown>)) {
235 const v = value as { name?: unknown; hash?: unknown } | null
236 if (typeof v === 'object' && v !== null && typeof v.name === 'string' && typeof v.hash === 'string' && /^[0-9a-f]{8}$/.test(v.hash)) out[root] = { name: v.name, hash: v.hash }
237 }
238 return out
239}
240
241/**
242 * The folder a mod of this name was last seen in when `root` itself is unseen and that folder is
243 * no longer among the ones checked: an update that installed into a new folder. A second folder
244 * with the name beside one still present (a copy under development) is new, not moved.
245 */
246export function movedFrom(seen: Record<string, SeenMod>, name: string, root: string, present: ReadonlySet<string>): string | undefined {
247 if (seen[root] !== undefined) return undefined
248 return Object.entries(seen).find(([r, s]) => s.name === name && !present.has(r))?.[0]
249}
250
251/** The pane as plain lines, for a surface that places no pane. */
252export function doorText(rows: readonly BouncerRow[]): string {
253 const lines = [`Bouncer: ${rows.length} mod${rows.length === 1 ? '' : 's'} checked. Facts from the validator, not a verdict.`]
254 for (const r of rows) {
255 lines.push(r.ok ? ` ${r.name}${r.version ? ` ${r.version}` : ''}: ${reachWords(r.reach).map(w => w.word).join(', ')}${r.isNew ? ' (new)' : r.isChanged ? ' (changed)' : ''}` : ` ${r.name}: could not check (${r.reason})`)
256 }
257 return lines.join('\n')
258}
259hooks/bolt.ts 109 lines1// Bolt sprites for mods, v1.0.0. GENERATED by brand/bolt-sprites/tools/build.py: edit design.py, not this file.
2// Copy this file into your mod's hooks/ folder (mods install on their own, so they cannot import from brand/).
3// Rules (brand/BRAND.md): pink is the antenna light only, blush the cheeks only. Never recolour, rotate or stretch.
4// Never draw Bolt over a safety hold, and never in a toast (toasts are uncoloured text).
5
6export const BOLT_PALETTE = {
7 B: '#C8F53C',
8 S: '#9BC21A',
9 O: '#FF5C8A',
10 K: '#FF8FB1',
11 E: '#101114',
12 W: '#FBFBF8',
13 G: '#8E95A3',
14} as const
15
16export type BoltSize = 'small' | 'tiny'
17export type BoltSmallPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep' | 'sweat' | 'think' | 'egg' | 'egg-wobble-l' | 'egg-wobble-r' | 'egg-crack-1' | 'egg-crack-2' | 'egg-crack-3' | 'egg-hatch'
18export type BoltTinyPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep'
19export type BoltPose = BoltSmallPose | BoltTinyPose
20
21/** One text cell: a character and its colours (absent = the surface's own). */
22export type BoltCell = { ch: string; fg?: string; bg?: string }
23
24/** Pixel rows: one palette letter per pixel, '.' empty. Heights are even (two pixel rows per cell). */
25export const BOLT_SPRITES = {
26 small: {
27 'idle': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '..SS..SS..'],
28 'run1': ['...OO.....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', 'SS......SS'],
29 'run2': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', '...SSSS...'],
30 'jump': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '...S..S...'],
31 'crash': ['......OO..', '.....S....', '..BBBBBB..', '.BBBBBBBB.', 'BEBEBBEBEB', 'BBEBBBBEBB', 'BEBEBBEBEB', 'BBBBEEBBBB', '.SSSSSSSS.', 'SS......SS'],
32 'cheer': ['S...OO...S', 'S...SS...S', 'SBBBBBBBBS', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBEEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
33 'sleep': ['....SS....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBBBBBBBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBBBBBBB', '.SSSSSSSS.', '..SS..SS..'],
34 'sweat': ['....OO....', '....SS...G', '..BBBBBB.G', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
35 'think': ['....OO....', '....SS....', '..BBBBBB..', '.BBWEBWEB.', 'BBBEEBEEBB', 'BBBBBBBBBB', 'BKKBBBBKKB', 'BBBBBEBBBB', '.SSSSSSSS.', '..SS..SS..'],
36 'egg': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
37 'egg-wobble-l': ['...OO.....', '...SS.....', '..BWBB....', '.BWBBBB...', '.BBBBBB...', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
38 'egg-wobble-r': ['.....OO...', '.....SS...', '....BWBB..', '...BWBBBB.', '...BBBBBB.', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
39 'egg-crack-1': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBBBBBB.', '.BBEBBBBS.', '..BBBBBS..', '...SSSS...'],
40 'egg-crack-2': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBBBB.', '.BBEBEBBS.', '..BBBBBS..', '...SSSS...'],
41 'egg-crack-3': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBEBB.', '.BBEBEBES.', '..BBBBBS..', '...SSSS...'],
42 'egg-hatch': ['......OO..', '......SS..', '....BWBBBB', '....B.B.B.', '..........', '..........', '.B.B.B.BB.', '.BSSSSSSB.', '..BBBBBS..', '...SSSS...'],
43 },
44 tiny: {
45 'idle': ['.O.', 'BEB', 'BBB', 'S.S'],
46 'run1': ['.O.', 'BEB', 'BBB', 'S.S'],
47 'run2': ['.O.', 'BEB', 'BBB', '.S.'],
48 'jump': ['.O.', 'BEB', 'BBB', '.S.'],
49 'crash': ['...', '.SO', 'BEB', 'SSS'],
50 'cheer': ['SOS', 'BEB', 'BBB', 'S.S'],
51 'sleep': ['.S.', 'BSB', 'BBB', 'S.S'],
52 },
53} as const
54
55/** Letters drawn over a whole cell (cell coordinates). */
56const GLYPHS: Record<string, readonly { row: number; col: number; ch: string; color: keyof typeof BOLT_PALETTE }[]> = {
57 'small/sleep': [{ row: 0, col: 8, ch: 'z', color: 'G' }],
58}
59
60/** The two run frames, in order. Swap them about every 120 ms while running. */
61export const BOLT_RUN = ['run1', 'run2'] as const
62
63function colour(key: string | undefined): string | undefined {
64 if (key === undefined || key === '.') return undefined
65 return (BOLT_PALETTE as Record<string, string>)[key]
66}
67
68/**
69 * Packs a pose into half-block text cells: rows / 2 lines of `cols` cells.
70 * Terminal: draw with Raster (see hatchling's rasterCells). Desktop: one fixed-width cell per character.
71 */
72export function boltCells(size: 'small', pose: BoltSmallPose): BoltCell[][]
73export function boltCells(size: 'tiny', pose: BoltTinyPose): BoltCell[][]
74export function boltCells(size: BoltSize, pose: BoltPose): BoltCell[][] {
75 const rows: readonly string[] = (BOLT_SPRITES[size] as Record<string, readonly string[]>)[pose] ?? []
76 const out: BoltCell[][] = []
77 for (let r = 0; r + 1 < rows.length; r += 2) {
78 const top = rows[r] ?? ''
79 const bottom = rows[r + 1] ?? ''
80 const line: BoltCell[] = []
81 for (let c = 0; c < top.length; c += 1) {
82 const t = colour(top[c])
83 const b = colour(bottom[c])
84 if (t === undefined && b === undefined) line.push({ ch: ' ' })
85 else if (b === undefined) line.push({ ch: '\u2580', fg: t })
86 else if (t === undefined) line.push({ ch: '\u2584', fg: b })
87 else if (t === b) line.push({ ch: '\u2588', fg: t })
88 else line.push({ ch: '\u2580', fg: t, bg: b })
89 }
90 out.push(line)
91 }
92 for (const glyph of GLYPHS[`${size}/${pose}`] ?? []) {
93 const line = out[glyph.row]
94 if (line !== undefined && glyph.col < line.length) line[glyph.col] = { ch: glyph.ch, fg: BOLT_PALETTE[glyph.color] }
95 }
96 return out
97}
98
99/** Merges a row of cells into runs of one style, for coloured Text spans. */
100export function boltRuns(line: readonly BoltCell[]): BoltCell[] {
101 const runs: BoltCell[] = []
102 for (const cell of line) {
103 const last = runs[runs.length - 1]
104 if (last !== undefined && last.fg === cell.fg && last.bg === cell.bg) last.ch += cell.ch
105 else runs.push({ ...cell })
106 }
107 return runs
108}
109types/index.d.ts 76 lines1// What bouncer keeps in $.state: every mod it checked, as the pane draws it;
2// the band's line while a mod is new or changed; which row is unfolded; and
3// when the last check ran.
4
5/** What a mod can reach, read off Claude Code's validator report. Facts, never a verdict. */
6export type BouncerReach = {
7 files: 'none' | 'read' | 'read-write'
8 processes: boolean
9 network: boolean
10 model: boolean
11 sound: boolean
12 env: boolean
13 /** It reads settings ($.settings.read, $.config.list). */
14 settings: boolean
15 /** It changes Claude Code settings ($.config.set). */
16 changesSettings: boolean
17 /** It sets environment variables ($.env.set). */
18 setsEnv: boolean
19 /** It runs Claude's own tools, Bash and file writes among them ($.tool.call). */
20 runsTools: boolean
21 /** It calls MCP servers' tools ($.mcp.call, $.mcp.connect). */
22 mcp: boolean
23 /** It can spend your login on API calls ($.session.authorize). */
24 usesLogin: boolean
25 /** It starts subagents ($.agent.spawn). */
26 startsAgents: boolean
27 /** It adds a tool or a subagent type Claude can call ($.tool.register, $.agent.register). */
28 addsTools: boolean
29 /** It adds rows to the conversation ($.session.append). */
30 addsToConversation: boolean
31 /** It reads the conversation ($.session.messages, $.session.turns). */
32 readsConversation: boolean
33 /** It messages other sessions or agents ($.session.send). */
34 messagesSessions: boolean
35 /** It can end the running turn ($.turn.abort). */
36 stopsTurns: boolean
37 /** It writes your clipboard ($.ui.copy). */
38 clipboard: boolean
39 /** It can start a turn or fill the prompt box as if you typed. */
40 typesForYou: boolean
41 /** It hooks tool calls with a hook that can refuse or rewrite them. */
42 changesToolCalls: boolean
43 /** It hooks your prompts with a hook that can drop or rewrite them. */
44 holdsPrompts: boolean
45 /** It changes the system prompt or what is sent for the model. */
46 changesPrompt: boolean
47 /** The events it hooks, as the validator names them. */
48 hooks: string[]
49 /** Every $.noun.method it calls. */
50 calls: string[]
51 /** The validator's own lines, cleaned, for the details view. */
52 notes: string[]
53}
54
55/** One checked mod: its report, or why it could not be checked. `isLoaded`: the engine named it joining this session. */
56export type BouncerRow =
57 | { ok: true; name: string; version?: string; root: string; isLoaded: boolean; reach: BouncerReach; hash: string; isNew: boolean; isChanged: boolean }
58 | { ok: false; name: string; version?: string; root: string; isLoaded: boolean; reason: string }
59
60declare module 'claude-code' {
61 interface PluginState {
62 bouncer: {
63 /** Every mod checked, our own included, by name. */
64 rows: BouncerRow[]
65 /** The band's one line while a mod is new or changed this session, else null. */
66 news: string | null
67 /** The row whose validator lines are unfolded, by plugin folder, else null. */
68 open: string | null
69 /** When the last check finished, ms since the epoch; 0 before the first. */
70 checkedAt: number
71 /** A check is running now. */
72 isChecking: boolean
73 }
74 }
75}
76