SLOPSHOPPER

bouncer

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

newpanebandcommandtoastprocess
★ 1v0.1.0MITupdated 2026-10-13modmyclaude/claude-code-mods/mods/bouncer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bouncer
│ ┃ Bouncer ✕ › fix the failing auth test and add an audit log call │ ┃ ▄▀▄ Bouncer │ ┃ ▀▀▀ ⏺ Read(src/auth.ts) │ ┃ Checked 1 mod at 08:53. Facts from the ⎿ Read 6 lines │ ┃ validator, not a verdict; nothing leaves ⏺ Update(src/auth.ts) │ ┃ the machine. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ bouncer loaded could not check: the report ⎿ 3 pass, 1 fail │ ┃ │ ┃ In the warning colour: runs programs, runs ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ tools, reaches the network, calls MCP │ ┃ tools, calls the API as you, starts ✻ Worked for 42s · done 4:20 PM │ ┃ subagents, can type for you, can change │ ┃ tool calls, messages other sessions, › /bouncer │ ┃ changes your settings. Bouncer never │ ┃ blocks, unloads or changes a mod. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Bouncer
▄▀▄ Bouncer ▀▀▀ Checked 1 mod at 08:53. Facts from the validator, not a verdict; nothing leaves the machine. bouncer loaded could not check: the report could not be rea 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.
README

<img src="../../brand/pixel-icons/svg/bouncer.svg" width="64" height="64" alt="">

Bouncer

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

Install

/plugin install bouncer --marketplace modmyclaude/claude-code-mods

Use it

  • At session start the validator runs once per plugin folder, in the background. Nothing is drawn while every mod is already known and its report unchanged.
  • The line above the prompt appears when a mod is new to you or its report changed, naming the mod and what is notable (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). Show opens the pane; /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.

Settings

Every setting has a default, so the notice at install about options not yet set is expected; change them in /config.

SettingDefaultWhat it does
claudeBinclaudeThe command the validator runs through: a name on your PATH or an absolute path.

What it can touch

  • Runs: 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.
  • Reads: ~/.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.
  • Writes: one value in its own store: for each plugin folder, the mod's name there and a short hash of its reach, so the line fires on first sight and on change only. An update that installs into a new folder is compared with the old folder's hash, and the old entry goes. Nothing else.
  • Session state: the rows as last checked, the line, which row is unfolded, when the check ran.
  • Network, model calls: none. Nothing leaves the machine.

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.

Limits

  • The validator must be reachable: with no claude on the PATH (the desktop app's own binary is not), set claudeBin to its path, or every row says could not check.
  • Folders come from the registry files and from mods the engine names as they join; a mod loaded another way (a --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.
  • At most 60 folders are checked. Each run of the validator takes about a second; the check runs in the background and the pane says when it is done.
  • The reach words are read from the validator's list of calls and hooks. A mod that reaches the world through another mod's tool, or through a settings hook, is not seen here.
  • A mod whose first check failed reads as new when a later check passes. A mod seen before keeps its last hash through a failed check.
  • The band, the pane and the transcript lines are checked through the engine's test kit on the terminal and desktop surfaces.

Built from a prompt

This mod was built from the Bouncer prompt.

More mods: the full catalogue.

Source 5 files
hooks/register.tsx 361 lines
1// 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}
361
hooks/bolt-draw.tsx 86 lines
1// 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}
86
hooks/door.ts 259 lines
1// 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}
259
hooks/bolt.ts 109 lines
1// 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}
109
types/index.d.ts 76 lines
1// 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