A live side pane of your beads: in progress, ready and blocked issues, 10 each, with counts. Read with bd, read-only, after Bash calls. Open it with /beads.

Small mods for Claude Code. Pick the ones you want and combine them.
Baselane mods are Claude Code plugins made of function hooks. Each mod does one thing: a guard that asks before rm -rf, a band with the session cost, a side pane with your git status, a sound when tests pass, a pirate voice for Claude's prose. There are 100+ mods in families such as guards, panes, meters, sounds and stats. Install one, or stack ten. They do not need each other.
You need Claude Code 2.1.288 or later, with mods.
claude plugin marketplace add baselane-sh/mods-catalog
claude plugin install guard-essentials@baselane-mods
claude plugin install receipt@baselane-mods
Start Claude Code. Now it asks before harsh commands, and /receipt prints a receipt of the session.
Browse every mod at https://baselane-sh.github.io/mods-catalog/. The gallery (baselane-mods) pins each mod to a reviewed commit, so a new mod reaches the gallery a short time after it reaches this repo. For update, uninstall, options and starter sets, read docs/INSTALL.md.
This repo also has a development marketplace, baselane-mods-dev, that tracks main. Use it only to try changes that are not released yet:
claude plugin marketplace add baselane-sh/mods
claude plugin install <name>@baselane-mods-dev
Each mod is released with a git tag <name>--v<version>.
Most mods have one rule. A pack is one mod with several rules from the same family. For example, guard-essentials holds several guards and band-pack puts several meters in one band.
guard-pack and infra-guard installed, the terraform destroy check runs two times, and you can see two questions.guard-essentials, cost-meter and git-pane work well together.The catalog below marks each pack with "pack".
Beads (bd) is an issue tracker that lives in your repo. 19 mods show your beads in Claude Code: bars above the prompt, panes, slash commands, a guard and reminders. They need bd 1.2 or later and a repo with a .beads folder. Without these, they do nothing.
The core set in one go (the bars, the panes, /ready and the guard):
claude plugin install beads-bars@baselane-mods
claude plugin install beads-pane@baselane-mods
claude plugin install epics-pane@baselane-mods
claude plugin install ready@baselane-mods
claude plugin install beads-guard@baselane-mods
Each beads mod is also in the catalog below, in its family.
ntfy-notify or long-run-notify, or a webhook URL for slack-notify or discord-notify. With a destination set, those mods send a short message only: a generic "needs input" text with the project folder name, or the project name and the seconds. One exception needs no setup: ci-band and ops-band ask GitHub, through your own gh login, for the latest Actions run of your branch every 2 minutes, sending the repository and branch name. Without gh, or with gh not logged in, they do nothing.sports-narrator makes one small model call at the end of a turn that used tools. It sends tool names, file names and the first two words of each shell command, never file contents. It spends tokens, so you must turn it on.bd on your machine, such as bd status --json and bd ready --json. No beads mod creates, changes or closes a bead. beads-guard asks before a bd command that deletes or rewrites beads.secret-output-guard tells Claude not to repeat a credential, auto-format and auto-lint tell Claude to read a file again, beads-prime adds the workflow text that bd prime prints from your repo's .beads files, and bead-claim-nudge and bead-commit-nudge add a one-line reminder at your next prompt./wrapped read records that the mods keep on your machine./handoff writes .claude/handoff.md and never overwrites it. session-journal appends to ~/.claude/journal.log. auto-format and auto-lint run your project's own formatter or linter fix, which can rewrite files. The descriptions say what each mod writes.lsof for port-watch, gh for ci-band, bd 1.2 or later for the beads mods, ps for process-pane, and on macOS pmset for battery-band and pgrep with osascript for now-playing.plugins/ and .claude-plugin/marketplace.json are generated. Do not edit them by hand.
node scripts/build.mjs # build one plugin folder per catalog entry
bash scripts/check.sh # rebuild, then validate, test and type-check every mod
bash scripts/check.sh git-pane # check only the mods you name
node scripts/gen-docs.mjs # rewrite the catalog in this README
node --test 'scripts/*.test.mjs' # test the docs generator
A change is done when check.sh prints check: 0 failure(s). Read AGENTS.md for the layout and the API facts.
To add a mod:
engines/<engine>/hooks/rules/<id>.ts, with a test in engines/<engine>/tests/<id>.test.ts.catalog/<name>.json: { "name", "description", "rules": ["<id>"] }. Give the description the slash command, if the mod has one (for example "Open it with /git"). The catalog table reads it from there.node scripts/build.mjs, then bash scripts/check.sh <mod>, then node scripts/gen-docs.mjs.A new engine needs a family in scripts/gen-docs.mjs. Keep files small, do not change inputs in place, and do not use the em-dash character.
MIT. Copyright (c) 2026 Baselane, LLC. Read LICENSE.
<!-- catalog:start -->
199 mods in 10 families.
"needs setup" means the mod has options that you set with claude plugin configure <mod>. Its description tells you if it works before you set them. "pack" means one mod with several rules.
Ask you before a harsh or risky command runs.
| Mod | What it does | Command | Notes | ||
|---|---|---|---|---|---|
beads-guard | Asks before bd commands that delete data or rewrite history: delete, purge, prune, gc, sql, admin, import, rename, forget, restore, migrate and more. Reads only the command text, runs no bd. | ||||
secret-filename-guard | Asks before a Bash command touches a secret-looking file (.env, private keys, credentials). | ||||
secret-guard | Asks before a live API key, token or private key is written, edited or run. | ||||
env-exfil-guard | Asks before a command prints your environment, echoes a secret variable or sends local data to a remote host. | ||||
infra-guard | Asks before terraform destroy, kubectl delete, force-push, DROP TABLE or rm -rf. | ||||
secret-commit-guard | Asks before a git commit that would record a credential or a secret-named file. Reads the staged diff with git. | ||||
protect-main | Asks before a commit, push or merge while you are on main or master. Reads the branch with git. | ||||
gitignore-check | Asks before git add or commit when secret-looking files are not ignored or are already tracked. Checks with git ls-files. | ||||
secret-output-guard | Tells Claude not to repeat a credential that showed up in command or file output, and names the source to rotate. | ||||
curl-pipe-guard | Asks before a download is piped into a shell or interpreter (curl \ | sh, wget -O- \ | bash, bash <(curl ...)). | ||
sudo-guard | Asks before sudo, doas or su -c runs a command with elevated rights. | ||||
no-verify-guard | Asks before git hooks are skipped (--no-verify, commit -n, HUSKY=0, core.hooksPath=/dev/null). | ||||
lockfile-guard | Asks before a lockfile is written or edited by hand; the package manager should change it. | ||||
package-guard | Asks before a new dependency is installed (npm, pnpm, yarn, bun, pip, uv, poetry, cargo, go, gem) and names the packages. | ||||
prod-db-guard | Asks before destructive SQL runs in a command: TRUNCATE, DELETE or UPDATE with no WHERE. | ||||
path-jail | Asks before Write, Edit or NotebookEdit changes a file outside the working directory, by real path. | ||||
docker-guard | Asks before Docker commands that delete data: system, volume or image prune, rm -f, volume rm and compose down -v. | ||||
k8s-guard | Asks before kubectl apply or replace with --force, kubectl drain, helm uninstall, and kubectl delete behind global flags (plain kubectl delete is infra-guard's). | ||||
migration-guard | Asks before Write or Edit changes a migration that already exists; new migration files pass. | ||||
ci-config-guard | Asks before Write or Edit changes CI config: .github/workflows, .gitlab-ci.yml or .circleci/config.yml. | ||||
chmod-guard | Asks before chmod makes files world-writable (777, a+w, o+w) or chmod or chown runs recursively on a broad path (/, a system folder, a home folder). | ||||
git-history-guard | Asks before git commands that throw away work or rewrite history: reset --hard, clean, rebase, filter-branch, filter-repo, push --delete, branch -D and stash clear. | ||||
big-file-guard | Asks before a Write creates content over 1 MB, or git add names a file over 5 MB. Measures the files with find. | ||||
publish-guard | Asks before a package is published: npm, pnpm, yarn or bun publish, cargo publish, twine upload, gem push, poetry or uv publish. Dry runs pass. | ||||
tag-guard | Asks before release tags go to a remote: git push --tags, --follow-tags or --mirror, a push of a tag ref, and a push that deletes a remote tag. Dry runs pass. Reads tags with git. | ||||
deploy-guard | Asks before a production deploy: vercel --prod, netlify deploy --prod, firebase deploy, fly deploy, gcloud app deploy, eb deploy, heroku rollback, serverless deploy to prod. | ||||
db-reset-guard | Asks before a framework wipes a database: prisma migrate reset, rails or rake db:drop and db:reset, alembic downgrade, django flush, supabase db reset, knex rollback --all. | ||||
ssh-guard | Asks before a private key in .ssh is read, authorized_keys or the SSH config is changed, or ssh-keygen would overwrite a key. Public keys and ssh -i pass. Reads HOME with printenv. | ||||
cron-guard | Asks before scheduled jobs or services are wiped or stopped: crontab -r, crontab replaced from stdin or a file, launchctl unload or bootout, systemctl stop, disable or mask. | ||||
upload-guard | Asks before local files go to a remote host: scp or rsync to host:path, piped input to nc, curl -T, sftp put. Local copies, downloads and localhost pass. | ||||
registry-push-guard | Asks before an image or chart is pushed to a registry: docker push, docker buildx --push, podman push, helm push, gcloud artifacts docker push. Local registries pass. | ||||
guard-essentials | Asks only before harsh or disaster commands: destructive infra, git and SQL, piping downloads into a shell, sudo, leaking or committing secrets. Reads git. The quiet choice for daily work. | pack | |||
guard-pack | The 15 core Baselane guards in one mod (secrets, git, infra, packages, path jail). Some read git, measure files with find, read HOME with printenv, or check where a path really lands. | pack | |||
guard-devops | Asks before harsh DevOps commands: destructive Docker, Kubernetes and Helm calls, CI config edits, broad chmod and chown, and git commands that lose work. Add infra-guard for plain kubectl delete. | pack | |||
release-pack | Asks before a release leaves your machine: package publish, tag push, production deploy and registry push guards in one mod. The tag guard reads tags with git. | pack | |||
ship-safe-pack | The release-pack guards plus db-reset-guard and upload-guard: asks before publish, tag push, production deploy, registry push, database wipes and file uploads. Reads tags with git. | pack |
One quiet toast at turn end when something needs your attention.
| Mod | What it does | Command | Notes |
|---|---|---|---|
bead-claim-nudge | At turn end, if Claude edited a file and no bead is in progress, shows a toast and reminds Claude at your next prompt, once per session. Reads with bd count; quiet without bd. | ||
bead-commit-nudge | At turn end, if a git commit named no bead id while a bead is in progress, shows a toast and reminds Claude at your next prompt. Reads with bd count and bd where; quiet without bd. | ||
test-reminder | Reminds you at turn end when source files changed but no test command ran since. | ||
ctx-nudge | Reminds you to /clear or /compact when the context window passes 75 percent. | ||
clippy | One helpful toast per session per trigger, in the classic paperclip voice: when you edit a migrations folder, a Dockerfile or a GitHub workflow, or run rm -rf. At most one toast per turn. | ||
sports-narrator | One line of sports play-by-play at turn end. OFF by default: one haiku call per turn with tools. Turn on the enabled option. Sends tool names, file names and the first two words of each command. | needs setup | |
commit-nudge | Suggests a commit at turn end after 8 or more file edits since the last git commit. One toast per batch of edits; a successful git commit resets the count. | ||
todo-nudge | At turn end, one toast with the count of TODO, FIXME and HACK lines your edits added this turn. Quiet when none were added. | ||
break-nudge | Suggests a short break once the session has run 90 minutes, and again every 90 minutes after that. | ||
docs-nudge | At turn end, one toast when edits added exported code (an export statement in .ts or .js, or a public function signature) and no README or docs file was edited this session. | ||
debug-print-nudge | At turn end, one toast naming the count of console.log, print(, debugger and dbg! lines your edits added this turn. Only code files count. | ||
typecheck-nudge | At turn end, one toast when .ts or .tsx files were edited and no type check (tsc, vue-tsc, a typecheck script or a build) ran since the last edit. | ||
lockfile-nudge | At turn end, one toast when dependencies in package.json, pyproject.toml, Cargo.toml or go.mod changed and neither the matching lockfile was edited nor an install command ran. | ||
big-diff-nudge | Suggests splitting the change once your edits added or removed more than 500 lines since the last git commit. A successful commit resets the count. | ||
migration-nudge | At turn end, one toast when a schema file changed (schema.prisma, models.py, SQL under schema/, a drizzle schema) and no migration file was created this session. | ||
env-example-nudge | At turn end, one toast when edits add a reference to an environment variable (process.env, os.environ, os.getenv, Deno.env) that .env.example does not list. Reads only .env.example names, never .env. | ||
nudge-pack | Two turn-end reminders in one mod: test-reminder and ctx-nudge. | pack | |
focus-pack | Three quiet nudges in one mod: commit after 8 edits, TODO/FIXME/HACK lines added, and debug prints added. | pack | |
quality-pack | Three quiet nudges in one mod: type check after TypeScript edits, lockfile after dependency edits, and a split suggestion past 500 changed lines. | pack |
Slash commands that print a result and, where it helps, copy it.
| Mod | What it does | Command | Notes |
|---|---|---|---|
ready | Adds /ready: the 20 top ready beads, one line each, and the total. Read with bd, the beads tracker. Read-only. Prints only, copies nothing. | /ready | |
bead | Adds /bead <id>: one bead with status, priority, labels, description, blockers and children. Read with bd, the beads tracker. Read-only. Prints only, copies nothing. | /bead | |
beads-standup | Adds /beads-standup: beads closed since yesterday, in progress, and the next 5 ready. Read with bd, the beads tracker. Read-only. Prints only, copies nothing. | /beads-standup | |
receipt | Adds /receipt: a shareable receipt of the session (tools, files, commands, blocks, context, cost), printed and copied. | /receipt | |
standup | Adds /standup: Yesterday, Today and Blockers from your git log and this session's record, printed and copied. | /standup | |
changelog | Adds /changelog: commits since the last tag (or the last 30), read with git log and grouped by conventional-commit type, as Markdown. Copies the result to your clipboard. | /changelog | |
pr-description | Adds /pr-description: title, summary, diff totals and a test plan stub for the current branch against main or master, read with git. Copies the result to your clipboard. | /pr-description | |
handoff | Adds /handoff: writes a session summary with the git status to .claude/handoff.md (never overwriting) and answers with the path. Copies the result to your clipboard. | /handoff | |
todos | Adds /todos: TODO, FIXME and HACK lines in tracked files, found with git grep, grouped by file (50 lines at most). Read-only. Copies the result to your clipboard. | /todos | |
loc | Adds /loc: lines of tracked text files by language, read with git, sorted, with a total. Skips lockfiles and binaries. Read-only. Copies the result to your clipboard. | /loc | |
hotspots | Adds /hotspots: the 10 files changed most often in the last 90 days, from git log, with change counts. Read-only. Copies the result to your clipboard. | /hotspots | |
commit-msg | Adds /commit-msg: a Conventional Commits message proposed from the staged diff, read with git. Heuristic, no model call, writes nothing. Copies the result to your clipboard. | /commit-msg | |
branches | Adds /branches: local branches merged into the default branch or idle for 30 days, as a cleanup list, read with git. Never deletes. Copies the result to your clipboard. | /branches | |
tree | Adds /tree: tracked files (git ls-files) as a tree, 2 levels deep, folders with file counts (80 lines at most). Read-only. Copies the result to your clipboard. | /tree | |
deps | Adds /deps: direct dependencies with versions from package.json, pyproject.toml, requirements.txt, go.mod and Cargo.toml at the repo root. No network. Read-only. Copies the result to your clipboard. | /deps | |
authors | Adds /authors: the top 15 contributors by commit count with their last commit date, from git log. Names only, never email addresses. Read-only. Copies the result to your clipboard. | /authors | |
scripts | Adds /scripts: runnable tasks from package.json scripts, Makefile targets, justfile recipes and pyproject scripts at the git repo root. Read-only. Copies the result to your clipboard. | /scripts | |
env-check | Adds /env-check: variable names in .env.example against .env at the git repo root, listing missing and extra names. Reads names only, never a value. Read-only. Copies the result to your clipboard. | /env-check | |
size | Adds /size: the 15 largest tracked files and the total tracked size at HEAD, read with git. Read-only. Copies the result to your clipboard. | /size | |
licenses | Adds /licenses: the licence of each direct dependency, from node_modules and Python dist-info at the git repo root. Shows unknown when it cannot tell. Read-only. Copies the result to your clipboard. | /licenses | |
conflicts | Adds /conflicts: tracked files that still hold merge conflict markers, with line numbers, found with git grep. Read-only. Copies the result to your clipboard. | /conflicts | |
secret-scan | Adds /secret-scan: tracked files and line numbers that hold secret-shaped text, found with git grep. Never prints a matched value. Read-only. Copies the result to your clipboard. | /secret-scan | |
envinfo | Adds /envinfo: versions of git, node, npm, python3, go, rustc and docker if installed, by running each (2 s limit), and the OS. Read-only. Copies the result to your clipboard. | /envinfo | |
stashes | Adds /stashes: git stashes with their age and the branch each was made on. Read-only, never applies or drops. Copies the result to your clipboard. | /stashes | |
recent | Adds /recent: your last 15 commits across all local branches (author is git user.name), with branch and date. Read-only. Copies the result to your clipboard. | /recent | |
file-owners | Adds /owners <path>: the top 5 authors of a file or folder by lines, from git blame. Names only, never addresses. Read-only. Copies the result to your clipboard. | /owners | |
readme-check | Adds /readme-check: missing README sections (install, usage, licence, contributing) and broken relative links, at the git repo root. Read-only. Copies the result to your clipboard. | /readme-check | |
command-pack | The five original slash commands in one mod: /receipt, /standup, /changelog, /pr-description and /handoff. They read git; /handoff also writes .claude/handoff.md. They copy results to the clipboard. | /receipt, /standup, /changelog, /pr-description, /handoff | pack |
repo-pack | Five read-only repo commands in one mod: /todos, /loc, /hotspots, /commit-msg and /branches. They read git. They copy results to the clipboard. | /todos, /loc, /hotspots, /commit-msg, /branches | pack |
explore-pack | Five read-only repo exploration commands in one mod: /tree, /deps, /authors, /scripts and /env-check. They read git and project files. They copy results to the clipboard. | /tree, /deps, /authors, /scripts, /env-check | pack |
audit-pack | Three read-only audit commands in one mod: /secret-scan, /conflicts and /licenses. They read git, project files and dependency folders. They copy results to the clipboard. | /secret-scan, /conflicts, /licenses | pack |
A one-line band above the prompt.
| Mod | What it does | Command | Notes |
|---|---|---|---|
cost-meter | A band above the prompt with the session cost so far and what the last turn cost. | ||
latte-meter | A band above the prompt that counts the session cost in lattes. Set the latte price in the plugin options. | needs setup | |
context-meter | A band above the prompt with a 10 cell bar of the context window used, yellow from 60 percent, red from 80. | ||
daily-spend | A band above the prompt with today's total cost across all your sessions. |
| pomodoro | A band above the prompt with a 25/5 focus timer (focus 18:42, break 03:10). Start and stop it with /pomodoro; a to
hooks/register.ts 13 lines1import type { Register } from 'claude-code'
2
3import { registerPanes, createPanes } from './engine'
4import { openPanesWithRun } from './hosts/panes-run'
5import { rule as beadsPane } from './rules/beads-pane'
6
7export const register: Register = on => {
8 const rules = [beadsPane]
9 const shared = createPanes()
10 registerPanes(on, rules, shared)
11 openPanesWithRun(on, rules, shared)
12}
13hooks/engine.tsx 213 lines1import { atom, read } from 'claude-code'
2import type { On, Timer, ToolCallEnvelope, ToolCallResult } from 'claude-code'
3
4import type { PaneLine, PaneView } from '../types'
5import { touchOf } from './files'
6import type { FileAction } from './files'
7import { line, redactLines } from './lines'
8import type { PaneHost, PaneRule } from './rule'
9import { paneTree } from './view'
10
11// The build writes the mod's own name in place of the token: `$.state` is
12// written only by the plugin that owns it.
13const views = atom({ plugin: 'beads-pane', key: 'views' } as const, {})
14
15// A pane reads the world at most once a second, however many calls ask,
16// unless its rule asks for a longer gap (`minGapMs`).
17const MIN_GAP_MS = 1_000
18
19// How long a load asked for at `now` must wait, given the last one began at
20// `last`: nothing, or the rest of the gap since it.
21export const waitBeforeLoad = (last: number | undefined, now: number, gap: number = MIN_GAP_MS): number =>
22 last === undefined ? 0 : Math.max(0, gap - (now - last))
23
24// What the engine's loads need from `$`, as closures built in a hook. A timer
25// keeps them past the hook's dispatch, as `$.clock.every` documents.
26export type Live = {
27 host: PaneHost
28 now: () => Promise<number>
29 isOpen: (id: string) => Promise<boolean>
30 write: (id: string, view: PaneView) => Promise<unknown>
31 log: (text: string) => unknown
32 after: (ms: number, fn: () => void) => Timer
33 every: (ms: number, fn: () => void) => Timer
34}
35
36// The pane loads every hook shares: one throttle and one timer per pane.
37export type Panes = {
38 request: (rule: PaneRule, live: Live) => Promise<void>
39 stop: (id: string) => void
40}
41
42export const RUN_TIMEOUT_MS = 10_000
43
44// A host that does not give `run`. A rule that calls it shows the failure in
45// its pane, and its tests fail: name it in engine.json `needs`.
46export const noRun = (): Promise<never> => Promise.reject(new Error('run is not given to this mod; name it in engine.json needs'))
47
48const message = (error: unknown): string => (error instanceof Error ? error.message : String(error))
49
50const failedLines = (error: unknown): PaneLine[] => [line('failed', { text: `Could not read: ${message(error)}`, color: 'red' })]
51
52// The loads every hook of the mod shares. Made once per load of the module,
53// by register.ts (engine.json shared).
54export const createPanes = (): Panes => {
55 // Timers and load times by pane id. Not drawn, so not kept in $.state; a
56 // hot reload drops them with the module, and the next load starts again.
57 const pollers = new Map<string, Timer>()
58 const trailing = new Map<string, Timer>()
59 const loadedAt = new Map<string, number>()
60
61 const stop = (id: string): void => {
62 pollers.get(id)?.cancel()
63 pollers.delete(id)
64 trailing.get(id)?.cancel()
65 trailing.delete(id)
66 }
67
68 // A closed pane stops its timer here too: the person's close may come
69 // when no hook of this module hears it.
70 const load = async (rule: PaneRule, live: Live): Promise<void> => {
71 const id = rule.pane.id
72 if (rule.load === undefined) return
73 if (!(await live.isOpen(id))) {
74 stop(id)
75 return
76 }
77 if (rule.everyMs !== undefined && !pollers.has(id)) {
78 pollers.set(id, live.every(rule.everyMs, () => void request(rule, live)))
79 }
80 let lines: PaneLine[]
81 try {
82 lines = await rule.load(live.host)
83 } catch (error) {
84 lines = failedLines(error)
85 await live.log(`${rule.id}: the pane could not read, ${message(error)}`)
86 }
87 await live.write(id, { at: await live.now(), lines: redactLines(lines) })
88 }
89
90 // Loads now, or once the gap since the last load is out. A burst of asks
91 // inside that gap ends in one load.
92 const request = async (rule: PaneRule, live: Live): Promise<void> => {
93 const id = rule.pane.id
94 if (trailing.has(id)) return
95 const now = await live.now()
96 const wait = waitBeforeLoad(loadedAt.get(id), now, Math.max(MIN_GAP_MS, rule.minGapMs ?? MIN_GAP_MS))
97 if (wait > 0) {
98 trailing.set(
99 id,
100 live.after(wait, () => {
101 trailing.delete(id)
102 void request(rule, live)
103 }),
104 )
105 return
106 }
107 loadedAt.set(id, now)
108 await load(rule, live)
109 }
110
111 return { request, stop }
112}
113
114// The hooks every pane mod has. The slash command, the tool call and the turn
115// hooks build the closures a load needs, so they are hosts (engine.json).
116export const registerPanes = (on: On, rules: readonly PaneRule[], panes: Panes): void => {
117 on('session.start', async ($, e, next) => {
118 for (const rule of rules) {
119 await $.command.register({ name: rule.pane.command, description: rule.pane.description, immediate: true })
120 }
121 return next(e)
122 })
123
124 for (const rule of rules) {
125 const { id } = rule.pane
126
127 // Must pass the close on: answering without `next` keeps the pane open.
128 on('ui.close', { id }, async ($, e, next) => {
129 panes.stop(id)
130 return next(e)
131 })
132
133 on('ui.render', { component: 'Pane', requestId: id }, async ($, e) => {
134 const { Box, Text } = $.ui.resolve(e)
135 const all: Readonly<Record<string, PaneView>> = await read($, views)
136 return paneTree({ Box, Text }, rule.pane, all[id], e.props.bodyColumns)
137 })
138 }
139}
140
141// The slash command: opens the pane and loads it, or closes an open one.
142export const togglePane = async (
143 rule: PaneRule,
144 panes: Panes,
145 live: Live,
146 ui: { open: () => Promise<unknown>; close: () => Promise<unknown> },
147): Promise<{ text: string }> => {
148 const { id, title, command } = rule.pane
149 if (await live.isOpen(id)) {
150 panes.stop(id)
151 await ui.close()
152 return { text: `${title} pane closed.` }
153 }
154 await ui.open()
155 await panes.request(rule, live)
156 return { text: `${title} pane opened. Run /${command} again or press Esc to close it.` }
157}
158
159// What a finished tool call writes, as closures over the hook's `$`.
160export type CallWrites = {
161 now: () => Promise<number>
162 countTurn: () => Promise<unknown>
163 touch: (path: string, action: FileAction, at: number) => Promise<unknown>
164 show: (id: string, view: PaneView) => Promise<unknown>
165 live: () => Live
166 log: (text: string) => unknown
167}
168
169// After each tool call: the turn and file ledgers, each watching rule's new
170// lines, then a load of each pane the call is due to refresh.
171export const afterToolCall = async (
172 rules: readonly PaneRule[],
173 panes: Panes,
174 e: ToolCallEnvelope,
175 next: (e: ToolCallEnvelope) => Promise<ToolCallResult>,
176 writes: CallWrites,
177): Promise<ToolCallResult> => {
178 const watchers = rules.filter(rule => rule.observe !== undefined)
179 const counts = rules.some(rule => rule.turns === true)
180 const keepsFiles = rules.some(rule => rule.files === true)
181 const startedAt = watchers.length > 0 ? await writes.now() : 0
182 const ran = await next(e)
183 try {
184 // Before any refresh below, so a load reads this call too.
185 if (counts) await writes.countTurn()
186 const touch = keepsFiles ? touchOf(e, ran) : undefined
187 if (touch !== undefined) await writes.touch(touch.path, touch.action, await writes.now())
188 if (watchers.length > 0) {
189 const at = await writes.now()
190 for (const rule of watchers) {
191 let lines: PaneLine[] | undefined
192 try {
193 lines = rule.observe?.({ e, ran, durationMs: at - startedAt })
194 } catch (error) {
195 lines = failedLines(error)
196 }
197 if (lines !== undefined) await writes.show(rule.pane.id, { at, lines: redactLines(lines) })
198 }
199 }
200 const due = rules.filter(rule => rule.load !== undefined && rule.refreshAfter?.(e) === true)
201 if (due.length > 0) {
202 const live = writes.live()
203 // Not awaited: the tool's result does not wait on a pane's refresh.
204 for (const rule of due) void panes.request(rule, live)
205 }
206 } catch (error) {
207 await writes.log(`pane: a tool call was not read, ${message(error)}`)
208 }
209 return ran
210}
211
212export const failedTurn = (where: string, error: unknown): string => `pane: a turn ${where} was not read, ${message(error)}`
213hooks/hosts/panes-run.ts 58 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import type { FileTouch, PaneView, TurnCost } from '../../types'
5import { afterToolCall, RUN_TIMEOUT_MS, togglePane } from '../engine'
6import type { Live, Panes } from '../engine'
7import { touchFile } from '../files'
8import type { PaneRule } from '../rule'
9import { countTool } from '../turns'
10
11// The build writes the mod's own name in place of the token: `$.state` is
12// written only by the plugin that owns it.
13const views = atom({ plugin: 'beads-pane', key: 'views' } as const, {})
14const turns = atom({ plugin: 'beads-pane', key: 'turns' } as const, [] as TurnCost[])
15const files = atom({ plugin: 'beads-pane', key: 'files' } as const, [] as FileTouch[])
16
17// The closures a load needs. Declared in this file, so the host follows `$`
18// into it; a timer keeps them past the hook's dispatch.
19const liveOf = ($: EngineInterface): Live => ({
20 host: {
21 run: argv => $.process.run(argv, { timeoutMs: RUN_TIMEOUT_MS }),
22 cwd: () => $.session.cwd(),
23 usage: () => $.session.usage(),
24 turns: () => read($, turns),
25 files: () => read($, files),
26 },
27 now: () => $.clock.now(),
28 isOpen: async pane => (await $.ui.panes()).some(open => open.id === pane),
29 write: (pane, view) => update($, views, all => ({ ...all, [pane]: view })),
30 log: text => $.ui.log(text),
31 after: (ms, fn) => $.clock.after(ms, fn),
32 every: (ms, fn) => $.clock.every(ms, fn),
33})
34
35// The pane's slash command and tool calls, for rules that run a program (git, ps, lsof).
36export const openPanesWithRun = (on: On, rules: readonly PaneRule[], panes: Panes): void => {
37 for (const rule of rules) {
38 const { id, title } = rule.pane
39 on('command.run', { command: rule.pane.command }, async $ =>
40 togglePane(rule, panes, liveOf($), {
41 open: () => $.ui.open({ id, title, closeOnEscape: true }),
42 close: () => $.ui.close({ id }),
43 }),
44 )
45 }
46
47 on('tool.call', async ($, e, next) =>
48 afterToolCall(rules, panes, e, next, {
49 now: () => $.clock.now(),
50 countTurn: () => update($, turns, countTool),
51 touch: (path, action, at) => update($, files, ledger => touchFile(ledger, path, action, at)),
52 show: (pane, view) => update($, views, all => ({ ...all, [pane]: view })),
53 live: () => liveOf($),
54 log: text => $.ui.log(text),
55 }),
56 )
57}
58hooks/rules/beads-pane.ts 76 lines1import type { PaneLine } from '../../types'
2import { BD_GAP_MS, NO_ANSWER, PRIORITY_COLORS, beadsOf, byPriority, readBd, refreshAfterBash } from '../beads'
3import type { Bead } from '../beads'
4import { clean, line, ruleLine } from '../lines'
5import type { PaneHost, PaneRule } from '../rule'
6
7// The beads (bd issues) in progress, ready and blocked, read with three
8// read-only bd calls, one after the other: bd's embedded database takes one
9// reader at a time best. Each section shows at most MAX_SHOWN, the footer
10// counts every issue of each list.
11const MAX_SHOWN = 10
12
13type Section = { key: string; title: string; color: string; args: readonly string[] }
14
15export const SECTIONS: readonly Section[] = [
16 { key: 'progress', title: 'In progress', color: 'cyan', args: ['list', '--status', 'in_progress', '--limit', '0'] },
17 { key: 'ready', title: 'Ready', color: 'green', args: ['ready', '--limit', '0'] },
18 { key: 'blocked', title: 'Blocked', color: 'red', args: ['blocked'] },
19]
20
21const beadLine = (section: string, bead: Bead, idWidth: number): PaneLine =>
22 line(
23 `${section}-${bead.id}`,
24 { text: ` ${clean(bead.id).padEnd(idWidth)} ` },
25 { text: `P${bead.priority}`, bold: true, ...(PRIORITY_COLORS[bead.priority] === undefined ? {} : { color: PRIORITY_COLORS[bead.priority] }) },
26 { text: ` ${clean(bead.title)}` },
27 )
28
29// The section's title, its first MAX_SHOWN issues by priority then age, and
30// a count of the rest.
31export const sectionLines = (section: Section, beads: readonly Bead[]): PaneLine[] => {
32 const shown = [...beads].sort(byPriority).slice(0, MAX_SHOWN)
33 const idWidth = Math.max(0, ...shown.map(bead => bead.id.length))
34 const rest = beads.length - shown.length
35 return [
36 line(`head-${section.key}`, { text: section.title, bold: true, color: section.color }),
37 ...(shown.length === 0 ? [line(`none-${section.key}`, { text: ' none', dim: true })] : shown.map(bead => beadLine(section.key, bead, idWidth))),
38 ...(rest > 0 ? [line(`more-${section.key}`, { text: ` … and ${rest} more`, dim: true })] : []),
39 ]
40}
41
42const footer = (counts: readonly number[]): PaneLine =>
43 line('counts', { text: SECTIONS.map((section, i) => `${counts[i] ?? 0} ${section.title.toLowerCase()}`).join(' '), dim: true })
44
45const load = async (host: PaneHost): Promise<PaneLine[]> => {
46 const cwd = await host.cwd()
47 const lists: Bead[][] = []
48 for (const section of SECTIONS) {
49 const read = await readBd(host, cwd, section.args)
50 // The first failure is the pane's one line; the other reads do not run.
51 if (!read.ok) return read.lines
52 const beads = beadsOf(read.value)
53 if (beads === undefined) return [line('bd-failed', { text: NO_ANSWER, dim: true })]
54 lists.push(beads)
55 }
56 return [
57 ...SECTIONS.flatMap((section, i) => [...(i === 0 ? [] : [ruleLine(`gap-${section.key}`)]), ...sectionLines(section, lists[i] ?? [])]),
58 ruleLine('gap-counts'),
59 footer(lists.map(list => list.length)),
60 ]
61}
62
63export const rule: PaneRule = {
64 id: 'beads-pane',
65 pane: {
66 id: 'beads',
67 title: 'Beads',
68 command: 'beads',
69 description: 'Show or hide the Beads pane: issues in progress, ready and blocked, read with bd',
70 empty: 'Reading beads…',
71 },
72 minGapMs: BD_GAP_MS,
73 refreshAfter: refreshAfterBash,
74 load,
75}
76hooks/files.ts 42 lines1import type { ToolCallEnvelope, ToolCallResult } from 'claude-code'
2
3import type { FileTouch } from '../types'
4
5// The files ledger: each file a Read, Edit or Write touched this session, with
6// a count per action. Pure: the engine reads and writes it in `$.state`.
7
8// The most recently touched files kept; the oldest drop off.
9export const MAX_FILES = 300
10
11export type FileAction = 'reads' | 'edits' | 'writes'
12
13const ACTIONS: Readonly<Record<string, FileAction>> = {
14 Read: 'reads',
15 Edit: 'edits',
16 MultiEdit: 'edits',
17 NotebookEdit: 'edits',
18 Write: 'writes',
19}
20
21const pathOf = (e: ToolCallEnvelope): string | undefined => {
22 const args = e as unknown as Readonly<Record<string, unknown>>
23 const path = args['file_path'] ?? args['notebook_path']
24 return typeof path === 'string' && path.length > 0 ? path : undefined
25}
26
27// The action a finished call took on a file, or undefined: a denied or failed
28// call touched nothing.
29export const touchOf = (e: ToolCallEnvelope, ran: ToolCallResult): { path: string; action: FileAction } | undefined => {
30 const action = ACTIONS[String(e.tool)]
31 const path = pathOf(e)
32 if (action === undefined || path === undefined || ran.deny !== undefined || ran.isError === true) return undefined
33 return { path, action }
34}
35
36// The ledger after one more touch: the file moves to the newest end.
37export const touchFile = (ledger: readonly FileTouch[], path: string, action: FileAction, at: number): FileTouch[] => {
38 const known = ledger.find(file => file.path === path) ?? { path, reads: 0, edits: 0, writes: 0, at }
39 const touched = { ...known, [action]: known[action] + 1, at }
40 return [...ledger.filter(file => file.path !== path), touched].slice(-MAX_FILES)
41}
42hooks/lines.ts 54 lines1import type { PaneCell, PaneLine } from '../types'
2import { redact } from './patterns'
3
4// Small builders the rules write their lines with, and the cuts the view
5// makes. Pure: nothing here touches the host.
6
7export const line = (key: string, ...cells: PaneCell[]): PaneLine => ({ key, cells })
8
9export const ruleLine = (key: string): PaneLine => ({ key, cells: [], isRule: true })
10
11// Every cell (and the key, which names a path) redacted whole, before any cut: a credential cut short no longer
12// matches its shape and would slip through.
13export const redactLines = (lines: readonly PaneLine[]): PaneLine[] =>
14 lines.map(each => ({ ...each, key: redact(each.key), cells: each.cells.map(cell => ({ ...cell, text: redact(cell.text) })) }))
15
16// Control characters (an ANSI color code, a tab) would draw as garbage or
17// shift the columns.
18export const clean = (text: string): string =>
19 text.replace(/\u001b\[[0-9;]*[A-Za-z]/g, '').replace(/[\u0000-\u001f\u007f]/g, ' ')
20
21const cut = (text: string, width: number): string => {
22 if (width <= 0) return ''
23 return text.length <= width ? text : `${text.slice(0, width - 1)}…`
24}
25
26// The cells that fit `columns`, left to right. The first that does not fit is
27// cut with an ellipsis to the last column, so the ones after it are dropped.
28export const fitCells = (cells: readonly PaneCell[], columns: number): PaneCell[] => {
29 const fitted: PaneCell[] = []
30 let used = 0
31 for (const cell of cells) {
32 const room = columns - used
33 if (room <= 0) break
34 const text = cut(cell.text, room)
35 if (text.length > 0) fitted.push({ ...cell, text })
36 used += text.length
37 }
38 return fitted
39}
40
41const two = (n: number): string => String(n).padStart(2, '0')
42
43export const timeOfDay = (at: number): string => {
44 const date = new Date(at)
45 return `${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}`
46}
47
48export const durationText = (ms: number): string => {
49 if (ms < 1000) return `${Math.round(ms)} ms`
50 if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`
51 const minutes = Math.floor(ms / 60_000)
52 return `${minutes} min ${Math.round((ms % 60_000) / 1000)} s`
53}
54hooks/rule.ts 54 lines1import type { ProcessRunResult, SessionUsage, ToolCallEnvelope, ToolCallResult } from 'claude-code'
2
3import type { FileTouch, PaneLine, TurnCost } from '../types'
4
5// What a rule may ask of the host while it reads the world. The engine hands
6// closures over `$`; `$` itself never comes here.
7export type PaneHost = {
8 // Runs a command by argv in the session's directory.
9 run: (argv: readonly string[]) => Promise<ProcessRunResult>
10 cwd: () => Promise<string>
11 usage: () => Promise<SessionUsage>
12 // The turns measured so far, oldest first (empty unless the rule asks for turns).
13 turns: () => Promise<readonly TurnCost[]>
14 // The files touched so far, oldest touch first (empty unless the rule asks for files).
15 files: () => Promise<readonly FileTouch[]>
16}
17
18// A finished tool call, for a rule that reads tool results.
19export type Observed = {
20 e: ToolCallEnvelope
21 ran: ToolCallResult
22 // How long the call took, by the host clock.
23 durationMs: number
24}
25
26export type PaneSpec = {
27 id: string
28 title: string
29 // The slash command that toggles the pane, without the slash.
30 command: string
31 description: string
32 // What the pane says before it has anything to show.
33 empty: string
34}
35
36// One live pane. `load` reads the world (on open, after the tool calls
37// `refreshAfter` names, and every `everyMs` while open); `observe` reads a
38// finished tool call and answers new lines, or undefined to keep the old.
39// `turns` has the engine measure each turn's cost and load after each turn.
40// `files` has the engine keep the files Read, Edit and Write touched.
41// `minGapMs` spaces the loads wider than the engine's one second, for a
42// program that is slow or costly to run.
43export type PaneRule = {
44 id: string
45 pane: PaneSpec
46 everyMs?: number
47 minGapMs?: number
48 turns?: boolean
49 files?: boolean
50 refreshAfter?: (e: ToolCallEnvelope) => boolean
51 load?: (host: PaneHost) => Promise<PaneLine[]>
52 observe?: (call: Observed) => PaneLine[] | undefined
53}
54hooks/view.tsx 57 lines1import type { BoxProps, ElementConstructor, RenderElement, TextProps } from 'claude-code'
2
3import type { PaneCell, PaneView } from '../types'
4import { fitCells, timeOfDay } from './lines'
5import type { PaneSpec } from './rule'
6
7// The two elements a pane draws with. Both surfaces it targets hand out
8// these constructors from `$.ui.resolve(e)`; `$` itself never comes here.
9export type PaneElements = {
10 Box: ElementConstructor<BoxProps>
11 Text: ElementConstructor<TextProps>
12}
13
14const cellText = (Text: PaneElements['Text'], cell: PaneCell): RenderElement => (
15 <Text
16 {...(cell.color === undefined ? {} : { color: cell.color })}
17 {...(cell.bold === true ? { bold: true } : {})}
18 {...(cell.dim === true ? { dimColor: true } : {})}
19 >
20 {cell.text}
21 </Text>
22)
23
24const rule = (columns: number): string => '─'.repeat(Math.max(0, columns))
25
26// The title and when the lines were read, a rule, then the lines, each cut
27// to `columns`. The rule's lines come newest first already.
28export const paneTree = (
29 { Box, Text }: PaneElements,
30 spec: PaneSpec,
31 view: PaneView | undefined,
32 columns: number,
33): RenderElement => {
34 const header = fitCells(
35 [{ text: spec.title, bold: true }, ...(view === undefined ? [] : [{ text: ` updated ${timeOfDay(view.at)}`, dim: true }])],
36 columns,
37 )
38 return (
39 <Box flexDirection="column">
40 <Box key="header" flexDirection="row">
41 {header.map(cell => cellText(Text, cell))}
42 </Box>
43 <Text dimColor>{rule(columns)}</Text>
44 {view === undefined && (
45 <Box key="empty">
46 <Text dimColor>{spec.empty}</Text>
47 </Box>
48 )}
49 {(view?.lines ?? []).map(each => (
50 <Box key={`line-${each.key}`} flexDirection="row">
51 {each.isRule === true ? <Text dimColor>{rule(columns)}</Text> : fitCells(each.cells, columns).map(cell => cellText(Text, cell))}
52 </Box>
53 ))}
54 </Box>
55 )
56}
57hooks/turns.ts 37 lines1import type { TurnCost } from '../types'
2
3// The turn ledger: each turn's cost is the session cost at its end less the
4// cost at its start, as the session cost is the only dollar figure the host
5// gives. Pure: the engine reads and writes the ledger in `$.state`.
6
7// Enough for a long session's average; the oldest drop off.
8export const MAX_TURNS = 200
9
10// A turn begins. A second start of the same turn changes nothing. `at`, when
11// given, is kept as the turn's start.
12export const startTurn = (ledger: readonly TurnCost[], turnId: string, usd: number | undefined, at?: number): TurnCost[] => {
13 if (ledger.some(turn => turn.turnId === turnId)) return [...ledger]
14 const n = (ledger.at(-1)?.n ?? 0) + 1
15 return [...ledger, { turnId, n, startUsd: usd ?? null, ended: false, ...(at === undefined ? {} : { startedAt: at, tools: 0 }) }].slice(
16 -MAX_TURNS,
17 )
18}
19
20// A turn ends. A turn the ledger never saw begin, or one already ended, is
21// left alone; a cost that fell (a cleared session) leaves it unmeasured.
22export const endTurn = (ledger: readonly TurnCost[], turnId: string, usd: number | undefined, at?: number): TurnCost[] =>
23 ledger.map(turn => {
24 if (turn.turnId !== turnId || turn.ended) return turn
25 const start = turn.startUsd
26 const measured = usd !== undefined && start !== null && usd >= start
27 return { ...turn, ended: true, ...(measured ? { usd: usd - start } : {}), ...(at === undefined ? {} : { endedAt: at }) }
28 })
29
30// One more tool call in the running turn: the newest, while it has not ended.
31// A call between turns belongs to none.
32export const countTool = (ledger: readonly TurnCost[]): TurnCost[] => {
33 const last = ledger.at(-1)
34 if (last === undefined || last.ended) return [...ledger]
35 return [...ledger.slice(0, -1), { ...last, tools: (last.tools ?? 0) + 1 }]
36}
37hooks/beads.ts 89 lines1import type { ProcessRunResult, ToolCallEnvelope } from 'claude-code'
2
3import type { PaneLine } from '../types'
4import { RUN_TIMEOUT_MS } from './engine'
5import { line } from './lines'
6import type { PaneHost } from './rule'
7
8// What the beads panes share: one read-only bd call, its failures as one line,
9// and the issue fields they draw. Only read commands with --json run here;
10// --readonly has bd itself refuse a write.
11
12// bd reads an embedded database: at most one load every 10 seconds.
13export const BD_GAP_MS = 10_000
14
15export const NO_PROJECT = 'No beads project in this folder.'
16export const NOT_INSTALLED = 'bd is not installed.'
17export const NO_ANSWER = 'bd did not answer.'
18
19// bd says this on stderr, exit 1, where no `.beads` is in the folder or above it.
20const NO_PROJECT_TEXT = /no beads (database|project) found/i
21// `$.process.run` rejects when bd cannot start (not installed) and when it
22// runs past the timeout (no answer). The message is the host's own wording,
23// so a reject that came near the timeout counts as one too.
24const TIMED_OUT = /time(d)?[ -]?out|still running|killed/i
25const NEAR_TIMEOUT_MS = RUN_TIMEOUT_MS - 1_000
26
27export const rejectText = (message: string, elapsedMs: number): string =>
28 TIMED_OUT.test(message) || elapsedMs >= NEAR_TIMEOUT_MS ? NO_ANSWER : NOT_INSTALLED
29
30export type BdRead = { ok: true; value: unknown } | { ok: false; lines: PaneLine[] }
31
32const failed = (text: string): BdRead => ({ ok: false, lines: [line('bd-failed', { text, dim: true })] })
33
34export const bdArgv = (cwd: string, args: readonly string[]): string[] => ['bd', '-C', cwd, '--readonly', ...args, '--json']
35
36export const refreshAfterBash = (e: ToolCallEnvelope): boolean => e.tool === 'Bash'
37
38// Runs one bd read in `cwd` and parses its JSON. Whatever goes wrong is one
39// line for the pane; bd's own error text is never shown.
40export const readBd = async (host: PaneHost, cwd: string, args: readonly string[]): Promise<BdRead> => {
41 let ran: ProcessRunResult
42 // Wall time, not the session clock: only the gap matters, and a mocked clock does not move while bd runs.
43 const startedAt = Date.now()
44 try {
45 ran = await host.run(bdArgv(cwd, args))
46 } catch (error) {
47 return failed(rejectText(error instanceof Error ? error.message : String(error), Date.now() - startedAt))
48 }
49 if (ran.exitCode !== 0) return failed(NO_PROJECT_TEXT.test(ran.stderr) ? NO_PROJECT : NO_ANSWER)
50 // A cut output would not parse, or worse, parse short.
51 if (ran.isStdoutTruncated) return failed(NO_ANSWER)
52 try {
53 return { ok: true, value: JSON.parse(ran.stdout) as unknown }
54 } catch {
55 return failed(NO_ANSWER)
56 }
57}
58
59export type Bead = { id: string; title: string; priority: number; createdAt: string }
60
61const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null
62
63// One issue of `bd list`, `bd ready` or `bd blocked`, or undefined for an
64// entry without an id, a title or a priority.
65export const beadOf = (value: unknown): Bead | undefined => {
66 if (!isRecord(value)) return undefined
67 const { id, title, priority, created_at: createdAt } = value
68 if (typeof id !== 'string' || id.length === 0 || typeof title !== 'string') return undefined
69 if (typeof priority !== 'number' || !Number.isInteger(priority)) return undefined
70 return { id, title, priority, createdAt: typeof createdAt === 'string' ? createdAt : '' }
71}
72
73// A list of issues, or undefined when the JSON is not one. bd may print null
74// for none.
75export const beadsOf = (value: unknown): Bead[] | undefined => {
76 if (value === null) return []
77 if (!Array.isArray(value)) return undefined
78 return value.flatMap(each => {
79 const bead = beadOf(each)
80 return bead === undefined ? [] : [bead]
81 })
82}
83
84// Priority first (0 is the highest), then the oldest created, then the id.
85export const byPriority = (a: Bead, b: Bead): number =>
86 a.priority - b.priority || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)
87
88export const PRIORITY_COLORS: Readonly<Record<number, string>> = { 0: 'red', 1: 'yellow' }
89hooks/patterns.ts 48 lines1// Shared secret shapes, ported from claude-secret-guard-kit
2// (hooks/secret-patterns.sh). Every guard rule reads them from here so the
3// shapes stay in sync.
4
5// Shapes of live credential VALUES. Spliced where the source text would
6// otherwise match the shape it defines.
7const SECRET_VALUE_SOURCES = [
8 'AKIA[0-9A-Z]{16}',
9 '-----BEGIN [A-Z ]*PRIVATE KEY-----',
10 'gh[pousr]_[A-Za-z0-9]{30,}',
11 'github_pat_[A-Za-z0-9_]{30,}',
12 'sk-ant-[A-Za-z0-9_-]{20,}',
13 'sk-(proj-)?[A-Za-z0-9_-]{32,}',
14 'AIza[0-9A-Za-z_-]{30,}',
15 'xox[baprs]-[A-Za-z0-9-]{10,}',
16 'hooks\\.slack\\.com/services/T[A-Za-z0-9]+/B[A-Za-z0-9]+/[A-Za-z0-9]+',
17 '[sr]k_live_[0-9a-zA-Z]{20,}',
18 'eyJ[A-Za-z0-9_-]{10,}\\.eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}',
19 '(postgres(ql)?|mysql|mongodb(\\+srv)?|redis|amqp|mssql)://[^:/@\\s]+:[^@\\s]+@',
20 // Any other URL with a user and a password, such as git+https://user:token@host.
21 '[a-z][a-z0-9+.-]*://[^:/@\\s]+:[^/@\\s]+@',
22 'npm_[A-Za-z0-9]{36}',
23 'SG\\.[A-Za-z0-9_-]{22}\\.[A-Za-z0-9_-]{43}',
24 'SK[0-9a-fA-F]{32}',
25 'service_' + 'account.{0,120}private_' + 'key_id',
26 'AccountKey=[A-Za-z0-9+/=]{60,}',
27 'hf_[A-Za-z0-9]{30,}',
28]
29
30export const SECRET_VALUE = new RegExp(SECRET_VALUE_SOURCES.join('|'))
31
32export const SECRET_VALUE_GLOBAL = new RegExp(SECRET_VALUE_SOURCES.join('|'), 'g')
33
34// Secret-looking FILE names. Whole-ish tokens to keep false positives low.
35export const SECRET_NAME =
36 /\.env(\.[A-Za-z0-9_-]+)?|\.pem|\.key|\.p12|\.pfx|\.keystore|\.jks|id_rsa|id_dsa|id_ecdsa|id_ed25519|secrets?\.(json|ya?ml|txt)|credentials|\.pgpass|\.htpasswd|\.npmrc|\.netrc|serviceaccount.*\.json|\.p8/i
37
38// Public env templates that must never trigger a prompt.
39export const SECRET_NAME_SAFE = /\.env\.(example|sample|template|dist|md)/g
40
41// Variable names whose values are secrets.
42export const SECRET_VAR_NAMES = '(KEY|SECRET|TOKEN|PASSWORD|PASSWD|PASS|CREDENTIAL|PRIVATE)'
43
44export const redact = (text: string): string => text.replace(SECRET_VALUE_GLOBAL, '[REDACTED]')
45
46// A path or command that names a secret-looking file, public templates aside.
47export const isSecretName = (text: string): boolean => SECRET_NAME.test(text.replace(SECRET_NAME_SAFE, ''))
48types/index.d.ts 70 lines1// The state a live side pane draws from. The build copies this file into the
2// mod as its contract (plugin.json "types"), with the token below replaced by
3// the mod's name: only the plugin that owns a `$.state` value may write it.
4
5// One run of text on a line. The text says everything; color only adds.
6export type PaneCell = {
7 text: string
8 color?: string
9 bold?: boolean
10 dim?: boolean
11}
12
13// One line of a pane, its cells drawn left to right and cut to the pane's
14// width. A line with `isRule` is a horizontal rule across the pane.
15export type PaneLine = {
16 key: string
17 cells: PaneCell[]
18 isRule?: boolean
19}
20
21// What a pane shows: its lines, already redacted, and when they were read.
22export type PaneView = {
23 at: number
24 lines: PaneLine[]
25}
26
27// One turn of the person's, as the engine measured it from the session cost
28// (kept only for a mod whose rule asks for turns).
29export type TurnCost = {
30 turnId: string
31 // 1 for the first turn the mod saw this session.
32 n: number
33 // The session cost in US dollars as the turn began; null where the host
34 // said none.
35 startUsd: number | null
36 ended: boolean
37 // What the turn cost, once it ended and both costs were known.
38 usd?: number
39 // When the turn began and ended, by the host clock (absent in a ledger
40 // written before they were kept).
41 startedAt?: number
42 endedAt?: number
43 // Tool calls made while the turn ran, subagents' included.
44 tools?: number
45}
46
47// One file Claude touched this session, with how often each action ran on it
48// (kept only for a mod whose rule asks for files).
49export type FileTouch = {
50 path: string
51 reads: number
52 edits: number
53 writes: number
54 // When it was last touched, by the host clock.
55 at: number
56}
57
58declare module 'claude-code' {
59 interface PluginState {
60 'beads-pane': {
61 // By pane id: one mod may draw several panes.
62 views: Record<string, PaneView>
63 // Oldest first.
64 turns: TurnCost[]
65 // Oldest touch first.
66 files: FileTouch[]
67 }
68 }
69}
70