A band above the prompt with the latest GitHub Actions run of your branch (ci passed, ci failed, ci running), asked of the gh CLI every 2 minutes; branch read…

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 18 lines1import type { Register } from 'claude-code'
2
3import { registerBand } from './engine'
4import { watchCallsWithRun } from './hosts/calls-run'
5import { startSessionWithFetchers } from './hosts/start-run'
6import { endTurnsWithFetchers } from './hosts/turn-end-run'
7import { stopTicks } from './hosts/session-end'
8import { rule as ciBand } from './rules/ci-band'
9
10export const register: Register = (on, options) => {
11 const rules = [ciBand]
12 registerBand(on, rules, options)
13 watchCallsWithRun(on, rules)
14 startSessionWithFetchers(on, rules)
15 endTurnsWithFetchers(on, rules)
16 stopTicks(on, rules)
17}
18hooks/engine.tsx 231 lines1import { atom, read, update } from 'claude-code'
2import type { Frozen, On, PluginOptions, SessionUsage, TurnCompleteInput } from 'claude-code'
3
4import type { Fetched, GitState, Outcome, Pomodoro, Reading, Tally } from '../types'
5import { localDate } from './date'
6import { hasFetchers } from './fetch'
7import { syncMinuteTick } from './minute'
8import type { MinuteDeps } from './minute'
9import type { BandRule, Segment, Ticker, TurnEnd, TurnTokens } from './rule'
10import { startTurnTimer, stopTurnTimer } from './turn'
11import { bandTree, fitSegments } from './view'
12
13// The build writes the mod's own name in place of the token: `$.state` is
14// written only by the plugin that owns it, and the scan wants the atoms here.
15const reading = atom({ plugin: 'ci-band', key: 'reading' } as const, null)
16const turnStartUsd = atom({ plugin: 'ci-band', key: 'turnStartUsd' } as const, null)
17// The state scan wants each atom spelled in the file that reads or writes it,
18// so watch.ts spells the same two. A turn end replaces `reading` whole, which
19// is why these live apart from it.
20const outcomes = atom({ plugin: 'ci-band', key: 'outcomes' } as const, [] as readonly Outcome[])
21const pomodoro = atom({ plugin: 'ci-band', key: 'pomodoro' } as const, null as Pomodoro | null)
22// Written by tally.ts and git.ts, which spell the same atoms; read here to draw.
23const tally = atom({ plugin: 'ci-band', key: 'tally' } as const, { calls: {}, failures: 0 } as Tally)
24const git = atom({ plugin: 'ci-band', key: 'git' } as const, null as GitState | null)
25// Written by the minute tick and read to draw, so the write redraws the band.
26const minute = atom({ plugin: 'ci-band', key: 'minute' } as const, 0)
27// Written by fetch.ts and turn.ts, which spell the same atoms; read here to draw.
28const fetched = atom({ plugin: 'ci-band', key: 'fetched' } as const, {} as Readonly<Record<string, Fetched | null>>)
29const turnStartedAt = atom({ plugin: 'ci-band', key: 'turnStartedAt' } as const, null as number | null)
30
31export const failed = (where: string, error: unknown): string =>
32 `band: ${where} skipped, ${error instanceof Error ? error.message : String(error)}`
33
34export const tickersOf = (rules: readonly BandRule[]): Ticker[] =>
35 rules.flatMap(rule => (rule.ticker === undefined ? [] : [rule.ticker]))
36
37const segmentsOf = (rules: readonly BandRule[], draw: Parameters<BandRule['segment']>[0]): Segment[] =>
38 rules.flatMap(rule => {
39 try {
40 const segment = rule.segment(draw)
41 return segment === undefined ? [] : [segment]
42 } catch {
43 // A rule that throws leaves its segment out and the rest draw.
44 return []
45 }
46 })
47
48// The turn's cache tokens as the rules read them, or none when it had no usage.
49const tokensOf = (usage: { input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number } | undefined): TurnTokens | undefined =>
50 usage === undefined ? undefined : { input: usage.input_tokens, cacheRead: usage.cache_read_input_tokens, cacheWrite: usage.cache_creation_input_tokens }
51
52const clampPercent = (percent: number | undefined): number | undefined =>
53 percent === undefined || !Number.isFinite(percent) ? undefined : Math.min(100, Math.max(0, percent))
54
55// The model is a read of its own: a failure leaves the figure out and the
56// rest of the turn end stands.
57const modelOf = async (read: () => Promise<string>): Promise<string | undefined> => {
58 try {
59 const model = (await read()).trim()
60 return model === '' ? undefined : model
61 } catch {
62 return undefined
63 }
64}
65
66// The tools a turn-end host does not give. A rule that calls one is logged and
67// skipped, and its tests fail: name what it uses in engine.json `needs`.
68const absent = (name: string) => (): Promise<never> =>
69 Promise.reject(new Error(`${name} is not given to this mod; name it in engine.json needs`))
70
71export const NO_MODEL: TurnIo['model'] = absent('model')
72export const NO_STORE: TurnIo['store'] = { get: absent('store'), set: absent('store') }
73
74// What a turn end reads and writes, as closures over the hook's `$`.
75// `refresh` runs the fetchers; only the host that runs programs gives it.
76export type TurnIo = {
77 usage: () => Promise<SessionUsage>
78 reading: () => Promise<Reading | null>
79 turnStartUsd: () => Promise<number | null>
80 keep: (now: Reading) => Promise<unknown>
81 spend: () => Promise<unknown>
82 endTimer: () => Promise<unknown>
83 model: () => Promise<string>
84 store: TurnEnd['store']
85 clock: Omit<MinuteDeps, 'tick'>
86 redraw: () => Promise<unknown>
87 log: (text: string) => unknown
88 refresh?: () => void
89}
90
91// The figures are read once, at the turn's end. A render hook never writes,
92// so they go into $.state for it to draw. A subagent's turn is not the
93// person's turn.
94export const turnComplete = async (rules: readonly BandRule[], e: Frozen<TurnCompleteInput>, io: TurnIo): Promise<void> => {
95 if (e.agentId !== undefined) return
96 const isFetching = hasFetchers(rules)
97 const isMinutely = isFetching || rules.some(rule => rule.everyMinute !== undefined)
98 if (rules.some(rule => rule.tracksTurn === true)) {
99 // First and on its own: a failed usage read below must not leave it running.
100 stopTurnTimer()
101 try {
102 await io.endTimer()
103 } catch (error) {
104 await io.log(failed('turn timer', error))
105 }
106 }
107 try {
108 const usage = await io.usage()
109 const usd = usage.cost?.usd
110 const percent = clampPercent(usage.context.percent)
111 const previous = await io.reading()
112 const base = (await io.turnStartUsd()) ?? previous?.usd
113 // A cost that fell (a cleared session) leaves the turn's cost unknown.
114 const turnUsd = usd !== undefined && base !== undefined && usd >= base ? usd - base : undefined
115 const date = localDate(await io.clock.now())
116 const model = await modelOf(io.model)
117
118 const usageTokens = tokensOf(e.usage)
119 let now: Reading = {
120 ...(usd === undefined ? {} : { usd }),
121 ...(turnUsd === undefined ? {} : { turnUsd }),
122 ...(percent === undefined ? {} : { percent }),
123 ...(Number.isFinite(usage.startedAt) ? { startedAt: usage.startedAt } : {}),
124 ...(model === undefined ? {} : { model }),
125 }
126 for (const rule of rules) {
127 if (rule.atTurnEnd === undefined) continue
128 try {
129 now = {
130 ...now,
131 ...(await rule.atTurnEnd({
132 reading: now,
133 previous: previous ?? {},
134 ...(usageTokens === undefined ? {} : { usage: usageTokens }),
135 turnUsd,
136 date,
137 store: io.store,
138 })),
139 }
140 } catch (error) {
141 await io.log(failed(rule.id, error))
142 }
143 }
144 await io.keep(now)
145 // Spent: a second end of the same turn adds nothing.
146 await io.spend()
147 if (isMinutely) {
148 const refresh = (): void => {
149 if (isFetching) io.refresh?.()
150 }
151 refresh()
152 await syncMinuteTick(rules, now, {
153 ...io.clock,
154 tick: async () => {
155 try {
156 refresh()
157 await io.redraw()
158 } catch (error) {
159 await io.log(failed('minute', error))
160 }
161 },
162 })
163 }
164 } catch (error) {
165 await io.log(failed('turn end', error))
166 }
167}
168
169// The hooks every band mod has: the cost as the turn begins, and the band.
170// The rest are hosts (engine.json), so a mod has only what its rules use.
171export const registerBand = (on: On, rules: readonly BandRule[], options: PluginOptions): void => {
172 const isMinutely = hasFetchers(rules) || rules.some(rule => rule.everyMinute !== undefined)
173 const isTurnTimed = rules.some(rule => rule.tracksTurn === true)
174 // The band reads the minute atom, so a tick's write redraws it: each minute,
175 // and each second while a turn runs.
176 const isTicking = isMinutely || isTurnTimed
177
178 // The cost as the turn begins: what this turn's cost is measured from.
179 on('turn.start', async ($, e, next) => {
180 try {
181 const usage = await $.session.usage()
182 await update($, turnStartUsd, () => usage.cost?.usd ?? null)
183 } catch (error) {
184 await $.ui.log(failed('turn start', error))
185 }
186 if (isTurnTimed) {
187 try {
188 // The callbacks close over `$`; they never pass it on.
189 await startTurnTimer({
190 now: () => $.clock.now(),
191 begin: at => update($, turnStartedAt, () => at),
192 every: (ms, fn) => $.clock.every(ms, fn),
193 tick: async () => {
194 try {
195 await update($, minute, n => n + 1)
196 } catch (error) {
197 await $.ui.log(failed('turn tick', error))
198 }
199 },
200 })
201 } catch (error) {
202 await $.ui.log(failed('turn timer', error))
203 }
204 }
205 return next(e)
206 })
207
208 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
209 if (e.props.hasSurvey) return next(e)
210 // A rule may draw from live state before the first turn ends.
211 const current = (await read($, reading)) ?? {}
212 const now = await $.clock.now()
213 const date = localDate(now)
214 const live = {
215 outcomes: await read($, outcomes),
216 pomodoro: await read($, pomodoro),
217 tally: await read($, tally),
218 git: await read($, git),
219 fetched: await read($, fetched),
220 turnStartedAt: await read($, turnStartedAt),
221 }
222 // Read only so its write redraws the band.
223 if (isTicking) await read($, minute)
224 const segments = fitSegments(segmentsOf(rules, { reading: current, options, date, now, ...live }), e.props.bodyColumns)
225 if (segments.length === 0) return next(e)
226
227 const { Box, Text } = $.ui.resolve(e)
228 return bandTree({ Box, Text }, segments)
229 })
230}
231hooks/hosts/calls-run.ts 68 lines1import { atom, read, update } from 'claude-code'
2import type { On } from 'claude-code'
3
4import type { Fetched, GitState, Outcome, Tally } from '../../types'
5import { failed } from '../engine'
6import { runFetchers, storeFetched } from '../fetch'
7import { ARGV, READ_TIMEOUT_MS, TOUCHING_TOOLS, refreshGit } from '../git'
8import type { BandRule } from '../rule'
9import { addCall } from '../tally'
10
11const WINDOW = 20
12
13// The same atoms as engine.tsx: the state scan wants each spelled in the
14// file that reads or writes it. The build writes the mod's own name for the token.
15const outcomes = atom({ plugin: 'ci-band', key: 'outcomes' } as const, [] as readonly Outcome[])
16const tally = atom({ plugin: 'ci-band', key: 'tally' } as const, { calls: {}, failures: 0 } as Tally)
17const git = atom({ plugin: 'ci-band', key: 'git' } as const, null as GitState | null)
18const fetched = atom({ plugin: 'ci-band', key: 'fetched' } as const, {} as Readonly<Record<string, Fetched | null>>)
19
20// Counts the git reads started, so only the newest one writes: a slow read
21// that started first never lands over a newer one. Not drawn state.
22let gitReads = 0
23
24// Watches each tool call for the rules that draw from how it ended (the recent
25// outcomes, the tally of the session) and for the rules that run a program:
26// the repository's git status, and the fetchers that read again after an
27// edit. The result is passed on as it came: a denied call stays denied. Only
28// one hook may answer `tool.call` without a matcher, so they share it.
29export const watchCallsWithRun = (on: On, rules: readonly BandRule[]): void => {
30 const has = (flag: 'tracksOutcomes' | 'tracksTools' | 'tracksGit'): boolean => rules.some(rule => rule[flag] === true)
31 const [isOutcomes, isTally, isGit] = [has('tracksOutcomes'), has('tracksTools'), has('tracksGit')]
32 const isFetch = rules.some(rule => rule.fetch?.onEdit === true)
33
34 on('tool.call', async ($, e, next) => {
35 const ran = await next(e)
36 try {
37 const outcome: Outcome = ran.deny !== undefined ? 'block' : ran.isError === true ? 'error' : 'ok'
38 if (isOutcomes) await update($, outcomes, recent => [...recent, outcome].slice(-WINDOW))
39 if (isTally) await update($, tally, current => addCall(current, e.tool, outcome !== 'ok'))
40 } catch (error) {
41 await $.ui.log(failed('outcomes', error))
42 }
43 // A denied call changed nothing. Not awaited: the tool's result does not
44 // wait on git, and refreshGit logs its own failures.
45 if (isGit && ran.deny === undefined && TOUCHING_TOOLS.has(e.tool)) {
46 gitReads += 1
47 const id = gitReads
48 void refreshGit({
49 run: () => $.process.run(ARGV, { timeoutMs: READ_TIMEOUT_MS }),
50 set: state => (id === gitReads ? update($, git, () => state) : undefined),
51 log: text => $.ui.log(text),
52 })
53 }
54 // A rule that reads a command's output asks again after the same calls,
55 // also not awaited.
56 if (isFetch && ran.deny === undefined && TOUCHING_TOOLS.has(e.tool)) {
57 void runFetchers(rules, 'edit', {
58 now: () => $.clock.now(),
59 run: (argv, timeoutMs) => $.process.run(argv, { timeoutMs }),
60 git: () => read($, git),
61 set: (id, value) => storeFetched(change => update($, fetched, change), id, value),
62 log: text => $.ui.log(text),
63 }, e.tool === 'Bash' ? { tool: e.tool, command: e.command } : { tool: e.tool })
64 }
65 return ran
66 })
67}
68hooks/hosts/start-run.ts 27 lines1import { atom, read, update } from 'claude-code'
2import type { On } from 'claude-code'
3
4import type { Fetched, GitState } from '../../types'
5import { runFetchers, storeFetched } from '../fetch'
6import type { BandRule } from '../rule'
7
8// The same atoms as engine.tsx: the state scan wants each spelled in the file
9// that reads or writes it. The build writes the mod's own name for the token.
10const git = atom({ plugin: 'ci-band', key: 'git' } as const, null as GitState | null)
11const fetched = atom({ plugin: 'ci-band', key: 'fetched' } as const, {} as Readonly<Record<string, Fetched | null>>)
12
13// The first figures of the fetching rules, which come with the session and
14// not the first turn end. Not awaited: the session does not wait on them.
15export const startSessionWithFetchers = (on: On, rules: readonly BandRule[]): void => {
16 on('session.start', async ($, e, next) => {
17 void runFetchers(rules, 'time', {
18 now: () => $.clock.now(),
19 run: (argv, timeoutMs) => $.process.run(argv, { timeoutMs }),
20 git: () => read($, git),
21 set: (id, value) => storeFetched(change => update($, fetched, change), id, value),
22 log: text => $.ui.log(text),
23 })
24 return next(e)
25 })
26}
27hooks/hosts/turn-end-run.ts 52 lines1import { atom, read, update } from 'claude-code'
2import type { On } from 'claude-code'
3
4import type { Fetched, GitState, Reading } from '../../types'
5import { NO_MODEL, NO_STORE, turnComplete } from '../engine'
6import { runFetchers, storeFetched } from '../fetch'
7import type { BandRule } from '../rule'
8
9// The same atoms as engine.tsx: the state scan wants each spelled in the file
10// that reads or writes it. The build writes the mod's own name for the token.
11const reading = atom({ plugin: 'ci-band', key: 'reading' } as const, null)
12const turnStartUsd = atom({ plugin: 'ci-band', key: 'turnStartUsd' } as const, null)
13const turnStartedAt = atom({ plugin: 'ci-band', key: 'turnStartedAt' } as const, null as number | null)
14const minute = atom({ plugin: 'ci-band', key: 'minute' } as const, 0)
15const git = atom({ plugin: 'ci-band', key: 'git' } as const, null as GitState | null)
16const fetched = atom({ plugin: 'ci-band', key: 'fetched' } as const, {} as Readonly<Record<string, Fetched | null>>)
17
18// The turn's end, for a mod whose rules read a figure from a program (a
19// fetcher): it runs them again then, and on each minute tick.
20// It gives no model read and no store (see turn-end-run-store.ts).
21export const endTurnsWithFetchers = (on: On, rules: readonly BandRule[]): void => {
22 on('turn.complete', async ($, e, next) => {
23 // The callbacks close over `$`; they never pass it on.
24 await turnComplete(rules, e, {
25 usage: () => $.session.usage(),
26 reading: () => read($, reading),
27 turnStartUsd: () => read($, turnStartUsd),
28 keep: (now: Reading) => update($, reading, () => now),
29 spend: () => update($, turnStartUsd, () => null),
30 endTimer: () => update($, turnStartedAt, () => null),
31 model: NO_MODEL,
32 store: NO_STORE,
33 clock: {
34 now: () => $.clock.now(),
35 after: (ms, fn) => $.clock.after(ms, fn),
36 every: (ms, fn) => $.clock.every(ms, fn),
37 },
38 redraw: () => update($, minute, n => n + 1),
39 log: text => $.ui.log(text),
40 refresh: () =>
41 void runFetchers(rules, 'time', {
42 now: () => $.clock.now(),
43 run: (argv, timeoutMs) => $.process.run(argv, { timeoutMs }),
44 git: () => read($, git),
45 set: (id, value) => storeFetched(change => update($, fetched, change), id, value),
46 log: text => $.ui.log(text),
47 }),
48 })
49 return next(e)
50 })
51}
52hooks/hosts/session-end.ts 17 lines1import type { On } from 'claude-code'
2
3import { stopMinuteTick } from '../minute'
4import type { BandRule } from '../rule'
5import { stopTurnTimer } from '../turn'
6
7// For the rules that tick (each minute, or each second of a turn): the tick
8// stops with the session. A /clear or a resume goes on in this process with
9// the band still drawn, so the tick goes on too.
10export const stopTicks = (on: On, _rules: readonly BandRule[]): void => {
11 on('session.end', async (_$, e, next) => {
12 if (e.reason !== 'clear' && e.reason !== 'resume') stopMinuteTick()
13 stopTurnTimer()
14 return next(e)
15 })
16}
17hooks/rules/ci-band.ts 52 lines1import type { Fetched } from '../../types'
2import type { BandRule } from '../rule'
3
4const FAILED = new Set(['failure', 'timed_out', 'startup_failure', 'action_required'])
5
6// The latest run as `gh run list --json status,conclusion` answers it: a JSON
7// array with at most one run. No run, or anything unexpected, shows nothing.
8export const parseRun = (stdout: string, branch: string): Fetched | null => {
9 let runs: unknown
10 try {
11 runs = JSON.parse(stdout)
12 } catch {
13 return null
14 }
15 const run = Array.isArray(runs) ? runs[0] : undefined
16 if (typeof run !== 'object' || run === null) return null
17 const { status, conclusion } = run as { status?: unknown; conclusion?: unknown }
18 if (typeof status !== 'string') return null
19 if (status !== 'completed') return { text: 'ci running', color: 'yellow', tag: branch }
20 if (conclusion === 'success') return { text: 'ci passed', color: 'green', tag: branch }
21 if (typeof conclusion !== 'string' || conclusion === '') return null
22 return FAILED.has(conclusion)
23 ? { text: 'ci failed', color: 'red', tag: branch }
24 : { text: `ci ${conclusion.replaceAll('_', ' ')}`, tag: branch }
25}
26
27// The latest GitHub Actions run of the current branch, asked of `gh` at most
28// every two minutes. The branch comes from the engine's git read, so nothing
29// shows before the first Bash call or edit. No gh, no login, no run, a
30// detached head or a failed call: hidden. A figure for another branch is
31// never drawn.
32export const rule: BandRule = {
33 id: 'ci-band',
34 tracksGit: true,
35 fetch: {
36 everyMs: 120_000,
37 timeoutMs: 10_000,
38 read: async (run, git) => {
39 if (git === null) return undefined
40 // A branch name never starts with a dash, so none is read as an option.
41 if (git.branch === 'detached' || git.branch.startsWith('-')) return null
42 const ran = await run(['gh', 'run', 'list', '--branch', git.branch, '--limit', '1', '--json', 'status,conclusion'])
43 return ran.exitCode === 0 ? parseRun(ran.stdout, git.branch) : null
44 },
45 },
46 segment: ({ fetched, git }) => {
47 const found = fetched['ci-band']
48 if (found === null || found === undefined || found.tag !== git?.branch) return undefined
49 return { key: 'ci-band', text: found.text, ...(found.color === undefined ? {} : { color: found.color }) }
50 },
51}
52hooks/date.ts 8 lines1const two = (n: number): string => String(n).padStart(2, '0')
2
3// The local calendar date, YYYY-MM-DD: "today" is the person's day, not UTC's.
4export const localDate = (at: number): string => {
5 const date = new Date(at)
6 return `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}`
7}
8hooks/fetch.ts 123 lines1import type { ProcessRunResult } from 'claude-code'
2
3import type { Fetched, GitState } from '../types'
4import type { BandRule, EditCall, Fetcher } from './rule'
5
6// What a run needs, as closures over `$`: `$` itself is never passed on.
7export type FetchDeps = {
8 now: () => Promise<number>
9 run: (argv: readonly string[], timeoutMs: number) => Promise<ProcessRunResult>
10 git: () => Promise<GitState | null>
11 // Stores the figure; answers whether it changed what was drawn.
12 set: (id: string, value: Fetched | null) => Promise<boolean>
13 log: (text: string) => unknown
14}
15
16export type FetchWhy = 'time' | 'edit'
17
18// Run bookkeeping, not drawn state: when each rule last started, which are
19// running, and which were asked again while running. A hot reload resets it.
20const startedAt = new Map<string, number>()
21const running = new Set<string>()
22// The call and generation behind each edit asked for again.
23const again = new Map<string, { call: EditCall | undefined; generation: number }>()
24// Edit triggers so far. A run reads under the generation it started in; runs
25// of one generation may share a read, and none shares one from before an edit.
26let edits = 0
27
28// An edit run is asked for by any file-touching call, or only by the calls a
29// rule's `onEditWhen` picks.
30const isAsked = (fetch: Fetcher, why: FetchWhy, call: EditCall | undefined): boolean =>
31 why === 'edit'
32 ? fetch.onEdit === true && (fetch.onEditWhen === undefined || (call !== undefined && fetch.onEditWhen(call)))
33 : fetch.everyMs !== undefined
34
35// A rate equal to the minute tick would skip every other tick when the last
36// run started a few ms after its own: a run this close to its rate is due.
37const RATE_SLACK_MS = 1000
38
39const isTooSoon = (fetch: Fetcher, last: number | undefined, now: number): boolean =>
40 last !== undefined && fetch.everyMs !== undefined && now - last < fetch.everyMs - RATE_SLACK_MS
41
42const runOne = async (
43 id: string,
44 fetch: Fetcher,
45 why: FetchWhy,
46 deps: FetchDeps,
47 generation: number,
48 call?: EditCall,
49): Promise<void> => {
50 if (!isAsked(fetch, why, call)) return
51 if (running.has(id)) {
52 // A file edit during a run may not be in its answer: run once more after it.
53 if (why === 'edit') again.set(id, { call, generation })
54 return
55 }
56 // Marked running before the first await, so two triggers at once start one
57 // run; the finally clears it, and runs an edit that came meanwhile.
58 running.add(id)
59 const last = startedAt.get(id)
60 try {
61 const now = await deps.now()
62 if (why === 'time') {
63 if (isTooSoon(fetch, last, now)) return
64 startedAt.set(id, now)
65 }
66 const value = await fetch.read(argv => deps.run(argv, fetch.timeoutMs), await deps.git(), now, generation)
67 if (value === undefined) {
68 // Nothing to ask yet: the run does not count against the rate.
69 if (why === 'time') last === undefined ? startedAt.delete(id) : startedAt.set(id, last)
70 } else {
71 await deps.set(id, value)
72 }
73 } catch (error) {
74 // A figure that could not be read again is dropped: a stale one misleads.
75 const isChange = await deps.set(id, null)
76 if (isChange) await deps.log(`band: ${id} skipped, ${error instanceof Error ? error.message : String(error)}`)
77 } finally {
78 running.delete(id)
79 const asked = again.get(id)
80 if (asked !== undefined) {
81 again.delete(id)
82 void runOne(id, fetch, 'edit', deps, asked.generation, asked.call).catch(() => undefined)
83 }
84 }
85}
86
87// Starts the runs that are due for `why`. Not awaited by a tool call or a
88// turn end: each run is bounded by its own timeout. `call` is the tool call
89// behind an edit run.
90export const runFetchers = async (rules: readonly BandRule[], why: FetchWhy, deps: FetchDeps, call?: EditCall): Promise<void> => {
91 if (why === 'edit') edits += 1
92 const generation = edits
93 await Promise.all(
94 rules.map(rule =>
95 rule.fetch === undefined
96 ? undefined
97 : runOne(rule.id, rule.fetch, why, deps, generation, call).catch(error => deps.log(`band: ${rule.id} skipped, ${String(error)}`)),
98 ),
99 )
100}
101
102type Figures = Readonly<Record<string, Fetched | null>>
103
104const isSame = (a: Fetched | null | undefined, b: Fetched | null): boolean =>
105 (a ?? null) === b || (a != null && b !== null && a.text === b.text && a.color === b.color && a.tag === b.tag)
106
107// Keeps one rule's figure in the atom, whole-map replaced, and answers whether
108// it differed. `write` is the caller's `update($, fetched, fn)`.
109export const storeFetched = async (
110 write: (change: (current: Figures) => Figures) => Promise<unknown>,
111 id: string,
112 value: Fetched | null,
113): Promise<boolean> => {
114 let isChange = false
115 await write(current => {
116 isChange = !isSame(current[id], value)
117 return isChange ? { ...current, [id]: value } : current
118 })
119 return isChange
120}
121
122export const hasFetchers = (rules: readonly BandRule[]): boolean => rules.some(rule => rule.fetch !== undefined)
123hooks/minute.ts 46 lines1import type { Timer } from 'claude-code'
2
3import type { Reading } from '../types'
4import type { BandRule } from './rule'
5
6const MINUTE_MS = 60_000
7
8// The minute tick, a module handle (not drawn state). A hot reload cancels it and
9// the next turn end starts it again.
10let minuteTick: Timer | undefined
11
12// The clock as closures over `$.clock`, and `tick`, which redraws the band.
13export type MinuteDeps = {
14 now: () => Promise<number>
15 after: (ms: number, fn: () => void) => Timer
16 every: (ms: number, fn: () => void) => Timer
17 tick: () => Promise<void>
18}
19
20export const stopMinuteTick = (): void => {
21 minuteTick?.cancel()
22 minuteTick = undefined
23}
24
25// Ticks on each minute while some rule wants it for `reading`, and stops once
26// none does. The first tick waits for the next whole minute, so a clock drawn
27// as HH:MM turns over when the minute does.
28export const syncMinuteTick = async (rules: readonly BandRule[], reading: Reading, deps: MinuteDeps): Promise<void> => {
29 // A rule that fetches on a rate needs the tick to come back to it.
30 const isWanted = (rule: BandRule): boolean => rule.everyMinute?.(reading) === true || rule.fetch?.everyMs !== undefined
31 if (!rules.some(isWanted)) return stopMinuteTick()
32 const now = await deps.now()
33 if (minuteTick !== undefined) return
34 let every: Timer | undefined
35 const first = deps.after(MINUTE_MS - (now % MINUTE_MS), () => {
36 void deps.tick()
37 every = deps.every(MINUTE_MS, () => void deps.tick())
38 })
39 minuteTick = {
40 cancel: () => {
41 first.cancel()
42 every?.cancel()
43 },
44 }
45}
46hooks/rule.ts 132 lines1import type { PluginOptions, ProcessRunResult } from 'claude-code'
2
3import type { Fetched, GitState, Outcome, Pomodoro, Reading, Tally } from '../types'
4
5// One piece of the band. `key` names its Text so a test or a host can find it.
6export type Segment = {
7 key: string
8 text: string
9 color?: string
10}
11
12// What a rule may read to name its segment.
13export type DrawContext = {
14 reading: Reading
15 options: PluginOptions
16 // The local date, YYYY-MM-DD.
17 date: string
18 // The outcomes of the last tool calls, oldest first.
19 outcomes: readonly Outcome[]
20 // The running pomodoro, or null.
21 pomodoro: Pomodoro | null
22 // The clock at this drawing, in milliseconds.
23 now: number
24 // Tool calls this session.
25 tally: Tally
26 // The repository's branch and changed files, or null.
27 git: GitState | null
28 // What the fetching rules last read, by rule id.
29 fetched: Readonly<Record<string, Fetched | null>>
30 // When the running turn began, or null between turns.
31 turnStartedAt: number | null
32}
33
34// The prompt cache tokens of one turn, summed over its requests.
35export type TurnTokens = {
36 // Uncached input, what the cache served, and what the turn wrote to it.
37 input: number
38 cacheRead: number
39 cacheWrite: number
40}
41
42// What a rule may touch at a turn end.
43export type TurnEnd = {
44 reading: Reading
45 // The reading the last turn end left, or empty before the first.
46 previous: Reading
47 // What the turn's requests spent on the prompt cache, when it had any.
48 usage?: TurnTokens
49 // What this turn cost, when known.
50 turnUsd: number | undefined
51 date: string
52 // The mod's own store. The engine hands closures over `$.store`.
53 store: {
54 get: (key: string) => Promise<unknown>
55 set: (key: string, value: unknown) => Promise<void>
56 }
57}
58
59// One rule of the band: the segment it draws, and optionally what it keeps at
60// a turn end (returned as the reading's new fields).
61export type BandRule = {
62 id: string
63 segment: (draw: DrawContext) => Segment | undefined
64 atTurnEnd?: (turn: TurnEnd) => Promise<Partial<Reading>>
65 // Set by a rule that draws from how the last tool calls ended. The engine
66 // then records each call's outcome into the `outcomes` atom.
67 tracksOutcomes?: true
68 // Set by a rule that draws from the tool calls of the whole session. The
69 // engine then counts each call, by tool, into the `tally` atom.
70 tracksTools?: true
71 // Set by a rule that draws from the repository. The engine then reads the
72 // branch and the changed files after a Bash call or a file edit.
73 tracksGit?: true
74 // Set by a rule that draws from the running turn. The engine then keeps when
75 // the turn began, and redraws each second while it runs.
76 tracksTurn?: true
77 // A rule that draws from a command's output (see Fetcher).
78 fetch?: Fetcher
79 // For a rule with a timer, started and stopped by a slash command.
80 ticker?: Ticker
81 // Set by a rule whose segment moves with the clock alone. While it answers
82 // true for the last turn's reading, the engine redraws the band on each
83 // minute, and only then.
84 everyMinute?: (reading: Reading) => boolean
85}
86
87// A file-touching tool call that can start an edit run: the tool, and the
88// command text of a Bash call.
89export type EditCall = {
90 tool: string
91 command?: string
92}
93
94// How a rule gets a figure from a command, never in a tool call's way: the
95// engine runs it beside the call, at most one run at a time per rule.
96export type Fetcher = {
97 // At most one run per this many milliseconds from the turn end, the session
98 // start and the minute tick. Absent: only after a file-touching tool call.
99 everyMs?: number
100 // Also run after a tool call that can change files (a Bash call, an edit).
101 onEdit?: true
102 // With onEdit, run only after the calls this answers true for.
103 onEditWhen?: (call: EditCall) => boolean
104 // A run is killed after this long and its figure is dropped.
105 timeoutMs: number
106 // `run` takes an argv (no shell). Answer what to draw, null to hide the
107 // segment, or undefined to leave the last figure as it is and not count the
108 // run (for a rule that has nothing to ask yet). A throw hides the segment.
109 // `now` is the clock at the run's start, in milliseconds. `generation`
110 // counts the edit triggers before the run: a read started in an older
111 // generation may not hold what the last edit changed.
112 read: (
113 run: (argv: readonly string[]) => Promise<ProcessRunResult>,
114 git: GitState | null,
115 now: number,
116 generation: number,
117 ) => Promise<Fetched | null | undefined>
118}
119
120// A timer the person starts and stops with `/<command.name>`. Pure, so a rule
121// never holds `$`: the engine registers the command, keeps the interval and
122// writes the timer into the `pomodoro` atom.
123export type Ticker = {
124 command: { name: string; description: string }
125 // One tick every `everyMs`, and so at most one redraw per tick.
126 everyMs: number
127 // The command ran at `now`: the timer it leaves (null stops it), and what to print.
128 toggle: (current: Pomodoro | null, now: number) => { next: Pomodoro | null; text: string }
129 // One tick at `now`: the timer after it, and a toast when something changed.
130 tick: (current: Pomodoro, now: number) => { next: Pomodoro; toast?: string }
131}
132hooks/turn.ts 32 lines1import type { Timer } from 'claude-code'
2
3const SECOND_MS = 1000
4
5// The one-second tick of the running turn, a handle like the minute tick.
6// A hot reload cancels it; the next turn start starts it again.
7let secondTick: Timer | undefined
8
9export const stopTurnTimer = (): void => {
10 secondTick?.cancel()
11 secondTick = undefined
12}
13
14// What the timer needs, as closures over `$`: `$` itself is never passed on.
15export type TurnTimerDeps = {
16 now: () => Promise<number>
17 // Keeps when the turn began (null: no turn runs).
18 begin: (at: number | null) => Promise<unknown>
19 every: (ms: number, fn: () => void) => Timer
20 // One redraw of the band.
21 tick: () => Promise<void>
22}
23
24// Keeps when the person's turn began for the rules that draw it, and redraws
25// the band each second while it runs. A subagent raises no turn.start, and its
26// turn.complete leaves the timer alone (the engine passes only the main loop's).
27export const startTurnTimer = async (deps: TurnTimerDeps): Promise<void> => {
28 stopTurnTimer()
29 await deps.begin(await deps.now())
30 secondTick = deps.every(SECOND_MS, () => void deps.tick())
31}
32