Draws the /accounts readout under its shell command and re-lays it out whenever the pane changes width

https://github.com/user-attachments/assets/5e078d27-f2cf-490f-b5d4-c979bec26f36
<b>The 29-second launch film.</b> Press play, then unmute.<br> <sub>The numbers are measured: 40 sessions at once and 1,366 agents in one day, counted from this machine’s Claude Code transcripts. The film is agent-made end to end: story, storyboard, 3D render and sound. 1920×1080, 60 fps. No player here? Open <a href="assets/hero/launch-film.mp4">the MP4 with sound</a> · <a href="tools/launch-film/README.md">how it was made</a>.</sub>
~/.claude, deployed from git.<br>The sessions run each other.<br>You only decide.1 · Deployed from git · 2 · The sessions run each other · 3 · You only decide · Where it stops · Install · Map
Claude Code reads everything it does from ~/.claude, and it is built for one session that a person watches. Run thirty sessions at once and three jobs land on that person. They must keep ~/.claude working, though it is unversioned machine state that every session depends on. They must keep the sessions out of each other's way, though all of them share one git index and one trunk. And they must judge when each session is really done, and which of its questions really need an answer.
This repo takes those three jobs over. ~/.claude is deployed from this git repo. The sessions start, place, message and retire one another. A session reaches you only with a decision it cannot make. One job is still yours, noticing a session stuck on a permission prompt, and the last section measures how long that takes. The repo is 7,309 files and 6,038 commits since 2026-03-24, checked by 16,159 tests, running on one Mac across four Claude accounts. Install takes five commands.
| What the system does | The job it takes off you | |
|---|---|---|
| 1 | The whole system deploys from git. Every change to the running system, from a hook to the Claude Code binary itself, is tested, landed, proven live, and can be reverted. | Keeping a hand-edited config dir alive |
| 2 | The sessions run each other. A session opens new sessions in new terminal panes, gives each its own branch and account, and hears back when they finish. Hooks can refuse any single command, and files, plans and transcripts outlive the pane. | Starting, placing and supervising sessions |
| 3 | You only decide. A session cannot call itself done while git says otherwise, and cannot hand you a task it could do itself. What reaches you is one decision or one command. | Judging "done", and sorting real questions from parked work |
A system that edits its own controller has to be versioned, tested and revertible like any other software, down to the binary it runs on. This repo is the source of truth, and ~/.claude is its deployment.
<!-- Diagram source: assets/diagrams/deploy-model.mmd — edit it, run npm run diagrams, commit the regenerated SVGs. --> <img src="assets/diagrams/deploy-model-light.svg" alt="The claude-infrastructure repo — 7,309 files under one reviewable history — deploys three ways. install.sh symlinks hooks, commands and scripts into the primary ~/.claude, so editing the live hook is editing the repo. install.sh --config-dir installs the same system into the four billing-isolated account directories; as deployed those directories symlink the code surfaces back to the primary, and isolate only their own auth and settings. Global surfaces — ~/bin tools, 101 cc tools, 28 LaunchAgents and the statusline — are copied, and sync.sh pulls hand-edits back.">
<!-- mermaid-fence: assets/diagrams/deploy-model.mmd (auto-synced by npm run diagrams) -->
flowchart TB
Repo["claude-infrastructure<br/>7,309 files · one reviewable history"]
Repo <-->|"install.sh · SYMLINK<br/>editing the live hook IS editing the repo"| Prim["~/.claude<br/>hooks · commands · scripts"]
Repo -->|"--config-dir · code SYMLINKED<br/>auth + settings per-account"| Alt["~/.claude-secondary … 4<br/>4 billing-isolated accounts"]
Repo -->|"COPY<br/>sync.sh pulls hand-edits back"| Glob["~/bin · LaunchAgents<br/>101 cc-* tools · 28 daemons · statusline"]
classDef src fill:#2b2410,stroke:#d4af37,color:#e6edf3
classDef dep fill:#0d1d2e,stroke:#58a6ff,color:#e6edf3
class Repo src
class Prim,Alt,Glob dep
<sup><a href="assets/diagrams/deploy-model-dark.svg?raw=true">full-screen dark</a> · <a href="assets/diagrams/deploy-model-light.svg?raw=true">light</a> · <a href="assets/diagrams/deploy-model.mmd">source</a></sup>
~/.claude is a symlink deployment of this repoEditing a live hook edits this repo, because the live hook is a symlink into it. The rule came from a failure: on 2026-07-03 a copied handoff-fire.sh had drifted 198 lines from the repo and was one install.sh away from being overwritten. ~/.claude is the primary deployment. Each of the four accounts has its own config dir (~/.claude-next, -secondary, -tertiary, -quaternary), which keeps its own login and settings but links its hooks/, commands/ and scripts/ back into this checkout, so one land reaches every account. Changes to settings, launchd jobs or credentials go through numbered, idempotent migrations (migrations/, 44 so far). Almost all of them wait for you to run, because an agent must not rewrite its own permissions.
The full test corpus runs on every tree that reaches trunk, just not inside the land. There are 16,159 bats tests in 801 files. A land runs only the suites its change touches. The whole corpus belongs to one background verifier, postland-verify.sh, which runs each trunk tree in a fresh checkout. When a tree goes red it bisects down to the one failing test, and re-runs the suspect commit's own tree before reverting it, so a flaky test cannot get an innocent commit reverted. Diagrams have their own check: npm run diagrams:check fails if a rendered SVG or an embedded mermaid block has drifted from its .mmd source.
A commit on trunk changes nothing until the shared checkout moves, and deploy-live.sh moves it after every land. Before that trigger, the median time from commit to live was 2.31 hours, and 40% of advances were run by hand. It moves to the newest tree the verifier has passed. If none is recent, it takes the newest tree not known to be red, with a banner and a page. If trunk is red all the way down, it refuses. It then re-runs install.sh so new files get their links, runs the host test suites against the live layer, and checks what the running processes actually loaded. A long-lived daemon can no longer run day-old code while the deploy reports success. A dead verifier or a lagging deploy is raised on cc-blockers rather than assumed healthy.
Every Claude Code version installs into its own directory, and one atomic symlink picks the active one. npm overwrites the binary in place, which throws ENOTEMPTY while live sessions hold its files. A symlink swapped by rename(2) is atomic where ln -sfn is not, and the kernel keeps a running binary alive even after its directory is deleted. claude-latest wraps the install and the clean-up. Before a version or model is promoted, cc-upgrade-gate.sh runs 15 checks against the candidate: model registration, auto mode, effort levels, spawn-depth limits, teammate, workflow and subagent spawns, hooks, resume, MCP servers and credential safety. It returns one GREEN or RED verdict. Then cc-lr upgrade moves idle sessions onto the new binary and model in place, one at a time, and never touches a session that is mid-turn. The default today is Opus 5.5 (claude-opus-5-5) on Claude Code 2.1.280. A second, stable track stays pinned at 2.1.114 until three upstream issues that block its next version are fixed.
A session is an addressable process, not a terminal you watch. It can open another session in a new pane, brief it, hear back from it, and close its own pane when the work has moved on. That stays safe because no session can collide with another's work, run a refused command, or take its work down with its pane.
<img src="assets/demo/handoff-live.webp" width="838" alt="Screen recording of one real terminal window in three captioned beats, played at about 1.8 times real speed. One: a real Claude session runs handoff-fire.sh with --split-right --notify-back, and the pane splits. Two: a second Claude session boots in the new pane, reads its brief, gets origin/main = 04c549d2, and reports the back-channel ping as verdict=delivered reason=wake-path-armed before running self-close --terminal. Three: the ping arrives inside the originator's own chat as PING RECEIVED FROM PEER with the peer's message, and the peer has closed its own pane — the window is back to one.">
<sub><b>A recording of two real Claude sessions in one window, played at about 1.8× speed</b> (roughly 50 seconds of real time in 30). One session fires a peer and the pane <b>splits</b>. The peer answers <code>origin/main = 04c549d2</code> and <b>pings back</b>, and the ping arrives <b>in the first session's chat</b>. The peer then <b>closes its own pane</b>. The only human keystroke is the first prompt. Recorded 2026-07-28 on iTerm2; kitty has been the only terminal in use since 2026-08-03, and the same commands drive it. <a href="assets/demo/handoff-live.mp4">Full-resolution video</a> (1920×1144, 60 fps).</sub>
Five commands cover a session's whole life: open, restart, move, message, close. To fire is to launch a new, briefed session in a new pane.
| Touchpoint | Command | What happens |
|---|---|---|
| Fire | /handoff → handoff-fire.sh --split-right | Splits the firing pane (never whichever window has focus), picks one of the four accounts on live quota, creates a worktree, then types and submits the brief. It waits until the new session has provably started work, then arms a /goal that states the end state. |
| Recycle | handoff-fire.sh --recycle [--worktree B] [--account A] | Types /exit and relaunches this pane with a fresh context, optionally on a new worktree or account. It refuses (exit 4) while subagents are still running, and names each one's partial transcript. |
| Switch | cc-lr switch | Moves a live session to another account in place: same pane, same session id, full transcript. Use it when the context is worth keeping and only the account paying for it is wrong. |
| Message | cc-notify · --notify-back · cc-await-ping | A fired session reports completion, a question or a blocker to the session that fired it. The reply channel is on by default for every fire. |
| Retire | handoff-fire.sh self-close --successor <uuid> | Closes the pane once the work has moved on. The successor must be alive and have taken a real turn first. --terminal, for a session with no successor, refuses (exit 8) while it still holds unlanded commits or unfinished scope. |
The same commands, recorded at the command line:
<img src="assets/demo/handoff-real.webp" width="838" alt="Terminal recording in three scenes. Scene 1, self-open: handoff-fire.sh --dry-run ranks all four accounts by live quota headroom, resolves the split anchor to the firing pane's own session id, and prints the exact composed launch command. Scene 2, two-way: cc-notify --self prints the pane uuid, cc-notify writes a message that appears as one timestamped line in the mailbox file, mailbox-drain.sh emits it as UserPromptSubmit additionalContext, and a second drain returns zero bytes because the seen cursor already consumed it. Scene 3, self-close: a bare self-close is refused for having no succession statement, and self-close --terminal is refused because an origin session was never fired by an originator.">
<sub>Recorded with <a href="https://github.com/charmbracelet/vhs">VHS</a> from <a href="assets/demo/handoff-real.tape"><code>assets/demo/handoff-real.tape</code></a>, so it can be re-run and cannot drift from the scripts it shows. The fire and self-close scenes use <code>--dry-run</code>; the mailbox round trip is real, against a temporary inbox.</sub>
A message is a file, not a keystroke. cc-notify appends one line to the recipient's inbox. The recipient reads it only at a moment when nothing it is typing can be corrupted, and marks it read exactly once:
<!-- Diagram source: assets/diagrams/session-comms.mmd — edit it, run npm run diagrams, commit the regenerated SVGs. --> <img src="assets/diagrams/session-comms-light.svg" alt="A message sent with cc-notify to a role is resolved at send time: if the target is live it goes straight to its mailbox file; if the pane recycled it follows a .forward chain to the successor; if the target is gone entirely it is tee'd to the desk tagged with the original uuid. The mailbox holds one line per message. It is drained at a safe boundary — SessionStart, UserPromptSubmit, or after a tool call at most once every 20 seconds — arrives as context rather than keystrokes on a live input line, and is acked at Stop through an exactly-once cursor. An idle peer is woken by the same write, through mailbox-wake-arm or cc-await-ping.">
<!-- mermaid-fence: assets/diagrams/session-comms.mmd (auto-synced by npm run diagrams) -->
flowchart TB
Box[("mailbox/<uuid>.md<br/>one line per message")]
Send["any session<br/>cc-notify --role peer"] --> Res{"resolved at<br/>SEND time"}
Res -->|"live"| Box
Res -->|"pane recycled"| Fwd[".forward chain<br/>→ the successor"]
Res -->|"gone entirely"| Desk["tee'd to the desk<br/>tagged for:<uuid>"]
Fwd --> Box
Desk --> Box
Box --> Drain["drained at a SAFE boundary<br/>SessionStart · UserPromptSubmit<br/>· after a tool call (≤ 1 per 20 s)"]
Drain --> Ctx["arrives as CONTEXT —<br/>never keystrokes on a live input line"]
Ctx --> Ack["acked at Stop:<br/>exactly-once cursor"]
Wake["idle peer?<br/>mailbox-wake-arm · cc-await-ping"] -.->|"the write IS the wake"| Box
classDef k fill:#2b2410,stroke:#d4af37,color:#e6edf3
classDef b fill:#0d1d2e,stroke:#58a6ff,color:#e6edf3
classDef g fill:#12261a,stroke:#3fb950,color:#e6edf3
class Res,Box k
class Send,Fwd,Desk,Drain b
class Ctx,Ack,Wake g
<sup><a href="assets/diagrams/session-comms-dark.svg?raw=true">full-screen dark</a> · <a href="assets/diagrams/session-comms-light.svg?raw=true">light</a> · <a href="assets/diagrams/session-comms.mmd">source</a></sup>
Each rule below exists because a failure happened first, and each is now enforced in code:
desk, the standing orchestrator session), which is resolved when the message is sent. A .forward chain follows each succession, and a line nobody can receive is still recorded and copied to the desk.cc-notify reports "mailbox only", and cc-inbox-guard fails loud on mail that nothing will read..seen cursor moves when a message is delivered. The .acked cursor moves only at the end of a turn that provably carried it. A crash mid-turn therefore shows the message again rather than losing it.Sessions that share one checkout share its git index and its trunk, so each writer gets a worktree and an account of its own, and every land waits for one machine-wide lock.
<!-- Diagram source: assets/diagrams/parallel-lanes.mmd — edit it, run npm run diagrams, commit the regenerated SVGs. --> <img src="assets/diagrams/parallel-lanes-light.svg" alt="Sessions A, B and C each work in their own worktree on their own account. All three run an unlocked gate that takes minutes — statics, ratchets and a bounded smoke that sheds by skipping under load, never a test corpus — then funnel into a single machine-wide land-lock held a median of 3 seconds (90th percentile 8 seconds), covering only the compare-and-swap push window. The push is verified by content rather than by commit count before the work reaches origin/main. Behind the trunk, one background verifier runs the full corpus in a fresh cell with host suites partitioned out, and deploy-live, kicked by every land, advances the live ~/.claude layer and never onto a RED tree.">
<!-- mermaid-fence: assets/diagrams/parallel-lanes.mmd (auto-synced by npm run diagrams) -->
flowchart LR
A["session A<br/>own worktree · acct 1"] --> G
B["session B<br/>own worktree · acct 2"] --> G
C["session C<br/>own worktree · acct 3"] --> G
G["gate — UNLOCKED, minutes<br/>statics + ratchets + bounded smoke<br/>(sheds by SKIPPING under load; no corpus, ever)"]
G --> Lock{"land-lock<br/>held p50 3 s (p90 8 s):<br/>the CAS push window only"}
Lock --> Push["push → verify by CONTENT,<br/>not commit count"]
Push --> Trunk[("origin/main")]
Trunk --> V["verifier — ONE, background band<br/>full corpus, fresh cell,<br/>host suites partitioned out"]
V -->|"verdict stamp"| D["deploy-live<br/>kicked by every land · never a RED tree"]
D --> Live[("live ~/.claude")]
classDef w fill:#0d1d2e,stroke:#58a6ff,color:#e6edf3
classDef k fill:#2b2410,stroke:#d4af37,color:#e6edf3
classDef t fill:#12261a,stroke:#3fb950,color:#e6edf3
class A,B,C w
class G,Lock,Push,V,D k
class Trunk,Live t
<sup><a href="assets/diagrams/parallel-lanes-dark.svg?raw=true">full-screen dark</a> · <a href="assets/diagrams/parallel-lanes-light.svg?raw=true">light</a> · <a href="assets/diagrams/parallel-lanes.mmd">source</a></sup>
git commit in a shared checkout sweeps up another session's staged files. So every writer works in its own worktree. An app repo can keep a pool of pre-built worktrees (scripts/worktree-pool.sh) and hand one out in about three seconds, with dependencies, generated code, .env.local and a seeded database ready; this repo is light enough to create them fresh. A hook refuses git commit in this repo's shared checkout outright, because one stray commit there once stopped the live layer from updating on every account.claude-accounts therefore picks, among accounts whose 5-hour window can take one more busy session, the one whose weekly quota resets soonest, so quota is spent before it expires. A session counts as busy if its transcript changed in the last ten minutes, never merely because its pane is open, so a burst of fires spreads down the ranking instead of piling onto one account. The session you type in follows the same rule, but only moves when another account wins by 15%. Recovery work uses a stricter rule that skips any account likely to hit its limit within the hour. Each term fails soft and has its own off switch. Design record: ACCOUNT_ROUTING_V2.md §13–15 and R2-policy-design.md.LAND_PIPELINE_V2.md). ship-land.sh checks only what the change can break: static checks, rules that may only tighten, and the test suites the change touches (180 s each, 900 s in total), which it skips when the machine is loaded. land-lock.sh holds the lock only for the push itself: 3 s median, 8 s at the 90th percentile, over 938 holds in 14 days. A whole land takes longer, about 15 minutes median and 43 at the 90th percentile, and most of that is the checks. These figures drift, so re-derive them rather than quote them: gate-red-census.sh and land-speed-census.py print each one with its coverage.land-verify.sh): every path is present on trunk and its diff is empty.Every prompt, tool call and turn-end passes through hooks that can refuse it. The live config wires 105 hook entries on 21 lifecycle events. They fail open, so a crashed hook never blocks anything. They refuse only on purpose: through PreToolUse denials, Stop blocks that send the turn back, and one prompt guard.
<!-- Diagram source: assets/diagrams/guardrail-pipeline.mmd — edit it, run npm run diagrams, commit the regenerated SVGs. --> <img src="assets/diagrams/guardrail-pipeline-light.svg" alt="A prompt passes through seven UserPromptSubmit hooks that deliver mail and nudges and refuse prompts typed into a stale copy of a moved session, then reaches Claude. Each tool call passes eighteen PreToolUse hooks: a refused call never runs, blocked by 41 deny rules, destructive bash, a commit in the shared checkout, or an unsanctioned push; an allowed call proce
hooks/register.tsx 234 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderInput } from 'claude-code'
3
4import type { BoardBand, Ladder } from '../types'
5import { bandLayout, isLadder, isReadoutCommand, pickVariant, spans } from './ladder'
6
7// THE /accounts READOUT, RE-LAID OUT ON RESIZE (2026-10-19). `claude-accounts --readout` used to
8// reach the pane only as a PostToolUse systemMessage: static text, which Claude Code re-wraps when
9// the pane narrows after the draw (operator screenshot: drawn at ~157 columns, viewed at ~35, every
10// grid row in pieces). A `ui.render` hook is re-run at every width change, completed transcript
11// rows included (docs/research/accounts-responsive-mod-2026-10-19/), so this mod draws the readout
12// under its shell-command row from the renderer's width ladder, picking the widest layout that
13// fits. Every fact and every layout is bin/claude-accounts's; nothing here lays anything out.
14//
15// The handshake with hooks/relay-verbatim.sh: before the command runs, `<sid>.mod` is stamped with
16// this call's tool_use_id; the hook, seeing its own call named there, sends the model its
17// do-not-paste note and draws no static copy. No stamp (mod not loaded) leaves the hook as it was.
18
19// THE SESSIONSTART BOARD, AS A BAND ABOVE THE PROMPT (2026-10-21). hooks/accounts-board.sh draws
20// the board as a SessionStart message: static text, hard-wrapped at the startup width, and Claude
21// Code has no render component for that message, so a pane widened later keeps a ~45-cell board.
22// So the band draws it from the producer's ladder, at the full band width, re-laid out at every
23// width change, until the first prompt. The handshake (measured, docs/research/accounts-board-band-
24// 2026-10-21/): `classic.SessionStart` runs above the settings hooks, so this mod stamps
25// `<sid>.board-band.req` with the session id, runs them, and takes the snapshot the hook leaves at
26// `<sid>.board-band.json` ONLY when it printed no grid. No snapshot, no band: the hook's text stands.
27//
28// A BRIEF SUBMITTED AT LAUNCH IS NOT THE OPERATOR'S FIRST PROMPT (2026-10-23). handoff-fire.sh,
29// --recycle and desk.zsh pass the brief as claude's positional prompt, which the engine submits the
30// moment the SessionStart hooks settle, as a `composer` prompt.submit byte-identical to a typed
31// Enter (no origin, field or event tells them apart). So every fired pane armed the band and lost it
32// within a second, with no static grid left (session #159; 72 of 85 band-era starts, every one of
33// the 41 handoff fires: 0.08-3.3 s from board to prompt, against 45 s and up for a manual start;
34// docs/research/accounts-board-band-2026-10-21/README.md § Fired panes). A submit inside the grace
35// window after the band goes up leaves it standing; the next prompt takes it away.
36const board = atom({ plugin: 'accounts-readout', key: 'board' } as const, null)
37
38const LADDERS = { plugin: 'accounts-readout', key: 'ladders' } as const
39const STORE_KEEP = 4 // ladders kept across sessions, for a resumed transcript
40const FIRST = ' ⎿ ' // the indent a hook message draws under a tool row
41const REST = ' '
42
43const paneDir = async ($: EngineInterface) =>
44 (await $.env.get('CC_PANE_DIR')) || '/tmp/claude-accounts-pane'
45
46const sidStem = (sid: string) => sid.replace(/[^A-Za-z0-9_-]/g, '')
47
48const remember = async ($: EngineInterface, id: string, ladder: Ladder) => {
49 await $.state.set({ ...LADDERS, id }, ladder)
50 const recent = ((await $.store.get('recent')) as string[] | undefined) ?? []
51 const keep = [id, ...recent.filter(x => x !== id)]
52 for (const old of keep.slice(STORE_KEEP)) await $.store.delete(`ladder:${old}`)
53 await $.store.set(`ladder:${id}`, ladder)
54 await $.store.set('recent', keep.slice(0, STORE_KEEP))
55}
56
57const recall = async ($: EngineInterface, id: string): Promise<Ladder | undefined> => {
58 const { value } = await $.state.get({ ...LADDERS, id })
59 if (isLadder(value)) return value
60 const stored = await $.store.get(`ladder:${id}`)
61 return isLadder(stored) ? stored : undefined
62}
63
64const draw = ($: EngineInterface, e: RenderInput, ladder: Ladder, engine: RenderElement) => {
65 const { Box, Text } = $.ui.resolve(e)
66 const columns = e.viewport?.columns ?? 80
67 const variant = pickVariant(ladder, columns)
68 if (!variant) return engine
69 return (
70 <Box flexDirection="column">
71 {engine}
72 {variant.lines.map((line, i) => (
73 <Text key={`l${i}`}>
74 {i === 0 ? FIRST : REST}
75 {spans(line).map((s, j) => (
76 <Text key={`s${j}`} color={s.color} bold={s.bold}>{s.text}</Text>
77 ))}
78 {line === '' ? ' ' : ''}
79 </Text>
80 ))}
81 </Box>
82 )
83}
84
85const seconds = (v: string | undefined, fallback: number) =>
86 v && /^[0-9]+$/.test(v) ? Number(v) : fallback
87
88const drawBand = async ($: EngineInterface, e: RenderInput<'AbovePrompt'>, b: BoardBand) => {
89 const { Box, Text } = $.ui.resolve(e)
90 const ageS = Math.max(0, Math.floor(((await $.clock.now()) - b.mtimeMs) / 1000))
91 // The band's height cap (maxRows) picks the layout as much as its width: the dense family where
92 // the full one would push accounts below the fold, and the short head where the hook's long one
93 // would (bandLayout). Head lines arrive pre-wrapped to the band, so Ink never breaks a word.
94 const { head, variant } = bandLayout(b.ladder, e.props.bodyColumns, e.props.maxRows, ageS, b.staleS, b.hardS)
95 return (
96 <Box flexDirection="column">
97 {head.map((line, i) => <Text key={`h${i}`} wrap="wrap">{line === '' ? ' ' : line}</Text>)}
98 {/* WRAP, NEVER TRUNCATE (2026-10-22). truncate-end cut every countdown in a 27-column band to
99 an ellipsis (operator screenshot). The ladder now reaches PANE_MIN (7 cells), so inside it
100 pickFit always finds a layout that fits and this never acts; below it, or on a ladder an
101 older producer wrote (35 cells and up), Ink wraps at whole words and nothing is hidden. */}
102 {(variant?.lines ?? []).map((line, i) => (
103 <Text key={`l${i}`} wrap="wrap">
104 {spans(line).map((sp, j) => (
105 <Text key={`s${j}`} color={sp.color} bold={sp.bold}>{sp.text}</Text>
106 ))}
107 {line === '' ? ' ' : ''}
108 </Text>
109 ))}
110 </Box>
111 )
112}
113
114export const register: Register = on => {
115 let tick: { cancel: () => void } | undefined
116 const stopTick = () => { tick?.cancel(); tick = undefined }
117
118 // Every source the hook draws for (startup, resume, clear, fork); never compact, which it skips.
119 on('classic.SessionStart', async ($, e, next) => {
120 if (e.source === 'compact') return next(e)
121 let armed: { req: string; snap: string } | undefined
122 try {
123 const stem = sidStem(e.session_id)
124 const surfaces = await $.session.surfaces()
125 if (stem && surfaces.some(s => s === 'terminal' || s === 'desktop')) {
126 const dir = await paneDir($)
127 armed = { req: `${dir}/${stem}.board-band.req`, snap: `${dir}/${stem}.board-band.json` }
128 await $.fs.write(armed.snap, '') // an earlier start's snapshot is not this one's
129 await $.fs.write(armed.req, e.session_id) // last: armed only once the stamp is down
130 }
131 } catch {
132 armed = undefined
133 }
134 const out = await next(e) // the settings hooks, accounts-board.sh among them
135 if (armed) {
136 try {
137 await $.fs.write(armed.req, '')
138 const text = await $.fs.read(armed.snap)
139 if (text) {
140 const ladder: unknown = JSON.parse(text)
141 const { mtimeMs } = await $.fs.stat(armed.snap)
142 await $.fs.write(armed.snap, '')
143 if (isLadder(ladder)) {
144 const snap: BoardBand = {
145 ladder, mtimeMs,
146 staleS: seconds(await $.env.get('CC_BOARD_STALE_S'), 300),
147 hardS: seconds(await $.env.get('CC_BOARD_HARD_S'), 3600),
148 raisedMs: await $.clock.now(),
149 graceS: seconds(await $.env.get('CC_BOARD_BAND_GRACE_S'), 20),
150 }
151 await update($, board, () => snap)
152 stopTick()
153 // The age label must stay true while the band waits for a first prompt.
154 tick = $.clock.every(30_000, () => $.ui.invalidate('ui.render'))
155 }
156 }
157 } catch {
158 // the hook printed no grid and this snapshot did not load: nothing more can be drawn
159 }
160 }
161 return out
162 }).catch(($, e, next) => next(e)) // replay-safe: a fault here never costs a hook's output
163
164 on('prompt.submit', async ($, e, next) => {
165 const b = await read($, board)
166 // Written as "not inside the grace": a band an older build raised (kept in $.state across a
167 // plugin hot reload) has no raisedMs, and NaN must clear it, not hold it forever.
168 if (b && !((await $.clock.now()) - b.raisedMs < b.graceS * 1000)) {
169 stopTick()
170 await update($, board, () => null)
171 }
172 return next(e)
173 }).catch(($, e, next) => next(e))
174
175 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
176 if (e.props.hasSurvey) return next(e)
177 const b = await read($, board)
178 if (!b) return next(e)
179 return drawBand($, e, b)
180 })
181
182 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
183 if (!isReadoutCommand(e.command)) return next(e)
184 let ladderPath: string | undefined
185 let startedMs = 0
186 try {
187 const stem = sidStem(await $.session.id())
188 if (stem) {
189 const dir = await paneDir($)
190 ladderPath = `${dir}/${stem}.ladder.json`
191 startedMs = await $.clock.now()
192 await $.fs.write(`${dir}/${stem}.mod`, e.tool_use_id)
193 }
194 } catch {
195 ladderPath = undefined // no stamp: the hook keeps drawing its static copy
196 }
197 const ran = await next(e)
198 if (ladderPath) {
199 try {
200 const ladder: unknown = JSON.parse(await $.fs.read(ladderPath))
201 // Written by THIS run (2 s slack for the two clocks), never a previous readout's.
202 if (isLadder(ladder) && ladder.written_ms >= startedMs - 2000) {
203 await remember($, e.tool_use_id, ladder)
204 }
205 } catch {
206 // no ladder: nothing to draw, and the hook (no fresh ladder) drew its static copy
207 }
208 }
209 return ran
210 }).catch(($, e, next) => next(e)) // never a guard: a fault here lets the call run
211
212 // The folded "Ran 1 shell command" line: the readout goes under it.
213 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
214 const call = e.props.calls.find(
215 c => c.tool === 'Bash' && !c.isRunning && !c.isErrored && c.tool_use_id !== undefined &&
216 isReadoutCommand((c.input as { command?: unknown } | null)?.command),
217 )
218 const ladder = call?.tool_use_id ? await recall($, call.tool_use_id) : undefined
219 if (!ladder) return next(e)
220 return draw($, e, ladder, await next(e))
221 })
222
223 // An unfolded call (`--verbose`, the ctrl+o transcript): under its own row.
224 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
225 const p = e.props
226 if (p.tool !== 'Bash' || p.isRunning || p.isErrored ||
227 !isReadoutCommand((p.input as { command?: unknown } | null)?.command)) return next(e)
228 const ladder = await recall($, p.tool_use_id)
229 if (!ladder) return next(e)
230 return draw($, e, ladder, await next(e))
231 })
232}
233
234hooks/ladder.ts 338 lines1import type { Ladder, Variant } from '../types'
2
3// The same test hooks/relay-verbatim.sh makes (`*claude-accounts*--readout*`, less the surfaces
4// that print their own output), so the mod draws exactly the calls the hook stops drawing.
5export const isReadoutCommand = (command: unknown): boolean =>
6 typeof command === 'string' &&
7 /claude-accounts[\s\S]*--readout/.test(command) &&
8 !/--(pane|narrow|json|ladder)\b/.test(command)
9
10export const isLadder = (value: unknown): value is Ladder => {
11 const l = value as Partial<Ladder> | null
12 return (
13 typeof l === 'object' && l !== null && l.v === 1 && typeof l.written_ms === 'number' &&
14 typeof l.gutter === 'number' && Array.isArray(l.variants) && l.variants.length > 0 &&
15 typeof l.budget?.from === 'number' && Array.isArray(l.budget.cells) && l.budget.cells.length > 0
16 )
17}
18
19// The layout the renderer itself would draw for a pane `columns` wide: its budget for that width
20// (pane_width, shipped in the ladder), then the widest variant laid out within it. A width off
21// the budget's ends takes the nearest end. Nothing is measured or computed here but an index.
22// The transcript readout has no height cap (it scrolls with the transcript), so it draws the FULL
23// family only; the dense one exists for the height-capped band (pickFit).
24export const pickVariant = (ladder: Ladder, columns: number): Variant | undefined => {
25 const { from, cells } = ladder.budget
26 const at = Math.max(0, Math.min(cells.length - 1, Math.floor(columns) - from))
27 const budget = cells[at] ?? 0
28 const full = ladder.variants.filter(v => !v.dense)
29 const pool = full.length > 0 ? full : ladder.variants
30 let best = pool[0]
31 for (const v of pool) if (v.cells <= budget) best = v
32 return best
33}
34
35export type Span = { text: string; color?: string; bold?: boolean }
36
37// The renderer's palette is three SGR forms (reset, `38;2;r;g;b`, `1;38;2;r;g;b`); read every
38// parameter list generically so a new one degrades to plain text, never to a raw escape.
39export const spans = (line: string): Span[] => {
40 const out: Span[] = []
41 let color: string | undefined
42 let bold = false
43 const re = /\x1b\[([0-9;]*)m/g
44 let at = 0
45 const push = (text: string) => {
46 if (text) out.push({ text, ...(color ? { color } : {}), ...(bold ? { bold } : {}) })
47 }
48 for (let m = re.exec(line); m; m = re.exec(line)) {
49 push(line.slice(at, m.index))
50 at = re.lastIndex
51 const ps = !m[1] ? [0] : m[1].split(';').map(Number)
52 for (let i = 0; i < ps.length; i++) {
53 const p = ps[i]
54 if (p === 0) { color = undefined; bold = false }
55 else if (p === 1) bold = true
56 else if (p === 22) bold = false
57 else if (p === 39) color = undefined
58 else if (p === 38 && ps[i + 1] === 2 && i + 4 < ps.length) {
59 const hex = ps.slice(i + 2, i + 5).map(n => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0'))
60 color = `#${hex.join('')}`
61 i += 4
62 } else if ((p === 38 || p === 48) && ps[i + 1] === 5) i += 2
63 else if (p === 48 && ps[i + 1] === 2) i += 4
64 }
65 }
66 push(line.slice(at))
67 return out
68}
69
70// THE SESSIONSTART BOARD AS A BAND (2026-10-21). A band never scrolls away, so it needs no
71// survive-one-split budget (docs/research/accounts-board-band-2026-10-21/): the widest layout whose
72// widest line fits the band's own cells; failing any fit, the narrowest there is, which drawBand
73// WRAPS (2026-10-22: the ladder starts at 7 cells, so that is a band under 7, or an old ladder).
74//
75// AND ITS HEIGHT (2026-10-22). Claude Code caps the band at `maxRows` (about half the pane: 10 on a
76// 30-row pane, 17 on 45, 30 on 70, measured on 2.1.293, docs/research/accounts-narrow-2026-10-22/)
77// and scrolls a taller tree inside it behind a `↓ N more` row, accounts below the fold. A tree of
78// exactly `maxRows` rows shows whole, with no cue (measured, captures/r4x-live-rows45-roomfull/):
79// the cue takes a row only from a tree that is already too tall. So among the layouts that fit the
80// width, the widest whose rows (with `headRows` above it) fit `maxRows`; failing that, the fullest
81// whose ACCOUNT rows fit above the cue (`values`: the rows through the last account, so the cut
82// falls in its alerts); failing that, the one with the fewest rows (ties: the widest). Both
83// families compete: the dense one (`dense: true`) is how a narrow band keeps every account on its
84// first screen. No `maxRows` (0, absent) is no height cap. Where both families fit, the FULL one
85// wins at any width: it says more, and ranking on width alone flipped the family as a tall band
86// widened (live rows, maxRows 30: full at 17 cells, dense at 20 for one cell of width, full again
87// at 22).
88const wider = (a: Variant, b: Variant) =>
89 a.width !== b.width ? a.width > b.width
90 : !!a.dense !== !!b.dense ? !a.dense
91 : a.cells > b.cells
92// TERSE (2026-10-22): a dense layout that spends rows on saying what its numbers are (the column
93// form's `reset` key row under ~17 cells) ships a `terse` sibling without them. Any labelled layout
94// is fuller than any terse one, whatever their widths: the labelled layout for a width may be an
95// earlier, narrower variant the ladder deduplicated into (identical lines), so a width-first rank
96// would hand a wider terse sibling a band its labelled one fits. The terse one is drawn only where
97// no labelled one fits the height (or keeps its accounts above the cue), and wins on rows alone.
98const fuller = (a: Variant, b: Variant) =>
99 !!a.dense !== !!b.dense ? !a.dense
100 : !!a.terse !== !!b.terse ? !a.terse
101 : wider(a, b)
102// The rows through the last account row; a ladder that does not say is all accounts.
103const valueRows = (v: Variant) => v.values ?? v.lines.length
104
105export const pickFit = (ladder: Ladder, cells: number, maxRows?: number, headRows = 0): Variant | undefined => {
106 const all = ladder.variants
107 const fitW = all.filter(v => v.width <= cells)
108 if (fitW.length === 0) {
109 return all.reduce<Variant | undefined>((best, v) =>
110 !best || v.width < best.width || (v.width === best.width && v.lines.length < best.lines.length)
111 ? v : best, undefined)
112 }
113 const room = maxRows && maxRows > 0 ? maxRows - headRows : Infinity
114 const fullest = (vs: Variant[]) => (vs.length > 0 ? vs.reduce((best, v) => (fuller(v, best) ? v : best)) : undefined)
115 // Labelled layouts first, whole, then with every account above the cue (a tree taller than the
116 // band shows one row fewer: the cue takes its last); a terse sibling only where no labelled one
117 // does either (2026-10-22: split lanes gave the fixture a 30-row terse sibling, which took a
118 // 70-row band from the full layout whose cut fell only in its alerts).
119 for (const fam of [fitW.filter(v => !v.terse), fitW.filter(v => v.terse)]) {
120 const got = fullest(fam.filter(v => v.lines.length <= room)) ?? fullest(fam.filter(v => valueRows(v) <= room - 1))
121 if (got) return got
122 }
123 return fitW.reduce((best, v) =>
124 v.lines.length < best.lines.length || (v.lines.length === best.lines.length && wider(v, best))
125 ? v : best)
126}
127
128// Greedy word wrap for the band's own head lines, so Ink never hard-wraps one (`claude-acc|ounts`
129// mid-word at a 12-cell band, operator goal 2026-10-22). Words split on spaces; a NBSP binds its
130// neighbours (`(5m old)`) unless the bound word is wider than a row, when it gives its NBSPs back.
131// A word still wider than a row breaks after a `-` that leaves a fitting piece (`claude-` /
132// `accounts`), else at the row edge. Every row after the first takes `cont` as its indent; the
133// first keeps the line's own leading spaces. Cells are code points: the head is all single-cell.
134const NB = '\u00a0'
135const cw = (s: string) => [...s].length
136
137// `edge`: whether a piece was cut at the row edge rather than after a `-` (`withhel` / `d`).
138const splitLong = (word: string, width: number): { pieces: string[]; edge: boolean } => {
139 const pieces: string[] = []
140 let rest = word
141 let edge = false
142 while (cw(rest) > width) {
143 const head = [...rest].slice(0, width).join('')
144 const cut = head.lastIndexOf('-')
145 const at = cut > 0 ? cut + 1 : head.length
146 if (cut <= 0) edge = true
147 pieces.push(rest.slice(0, at))
148 rest = rest.slice(at)
149 }
150 if (rest) pieces.push(rest)
151 return { pieces, edge }
152}
153
154// The rows, and whether any word had to be cut at the row edge (bandHeads offers no such form).
155const wrapRows = (line: string, cells: number, cont = ''): { rows: string[]; cut: boolean } => {
156 const width = Math.max(1, Math.floor(cells))
157 const lead = line.slice(0, line.length - line.trimStart().length)
158 const fresh = Math.max(1, width - cw(cont)) // what a continuation row holds
159 // `piece`: the rest of a word broken at the row edge, which must start its own row.
160 const words: { t: string; piece: boolean }[] = []
161 let cut = false
162 for (const word of line.trimStart().split(' ')) {
163 if (word === '') continue
164 const bound = word.split(NB)
165 for (const p of cw(word) > fresh && bound.length > 1 ? bound : [word]) {
166 const s = cw(p) > fresh ? splitLong(p, fresh) : { pieces: [p], edge: false }
167 cut = cut || s.edge
168 s.pieces.forEach((t, i) => words.push({ t, piece: i > 0 }))
169 }
170 }
171 const rows: string[] = []
172 let cur: string | undefined
173 for (const { t, piece } of words) {
174 if (cur === undefined) cur = cw(lead + t) <= width ? lead + t : t
175 else if (!piece && cw(`${cur} ${t}`) <= width) cur += ` ${t}`
176 else { rows.push(cur); cur = cont + t }
177 }
178 rows.push(cur ?? '')
179 return { rows: rows.map(r => r.split(NB).join(' ')), cut }
180}
181
182export const wrapWords = (line: string, cells: number, cont = ''): string[] =>
183 wrapRows(line, cells, cont).rows
184
185// hooks/accounts-board.sh's fmt_age, so the band labels an age exactly as the hook would.
186export const fmtAge = (s: number): string =>
187 s < 120 ? `${s}s` : s < 7200 ? `${Math.floor(s / 60)}m` : `${Math.floor(s / 3600)}h`
188
189// The hook's own staleness bands, applied to a band that may sit above the prompt for hours: fresh
190// shows the board alone (as the hook does), stale puts the hook's age line over it, and past the
191// hard band the numbers are withheld. `grid` false means draw no board lines.
192//
193// NARROW HEADS (2026-10-22). The hook's sentences cost a 12-cell band 9-15 rows and broke
194// `claude-accounts` mid-word, so `forms` lists each head fullest first, every one pre-wrapped to
195// `cells` by wrapWords: the hook's own sentences (the stale one with, then without, its blank
196// row), and from SHORT_HEAD_CELLS down only the short form (`⚠ stale 5m` / `live:
197// claude-accounts`; `numbers withheld (2h old)` / `run claude-accounts`). bandLayout picks one.
198export const SHORT_HEAD_CELLS = 25
199
200const wrapHead = (lines: string[], cells: number) => {
201 let cut = false
202 const rows = lines.flatMap(l => {
203 if (l === '') return ['']
204 const w = wrapRows(l, cells, l.startsWith(' ') ? ' ' : '')
205 cut = cut || w.cut
206 return w.rows
207 })
208 return { rows, cut }
209}
210
211// NO HEAD SPLITS A WORD (2026-10-22). At the 7-cell floor (a 12-column pane) `accounts` and
212// `withheld` are 8 cells, so the short heads drew `account` / `s` and `withhel` / `d`. A form that
213// must cut a word at the row edge is not offered; a cut after a `-` (`claude-` / `accounts`) is a
214// word break and stays. Should every form need a cut, the last, the shortest, is kept: a band never
215// loses its head.
216const wholeForms = (forms: string[][], cells: number): string[][] => {
217 const w = forms.map(f => wrapHead(f, cells))
218 const whole = w.filter(x => !x.cut).map(x => x.rows)
219 return whole.length > 0 ? whole : [w[w.length - 1].rows]
220}
221
222export const bandHeads = (ageS: number, staleS: number, hardS: number, cells = Infinity):
223 { forms: string[][]; grid: boolean } => {
224 const age = fmtAge(ageS)
225 const wide = cells >= SHORT_HEAD_CELLS
226 if (ageS >= hardS) {
227 const long = [
228 `accounts board is ${age} old${NB}— numbers withheld as unsafe to read (the 5h window it reports rolls every 5h, so a board this old can describe a window that has ended).`,
229 ' For the live table, run: claude-accounts',
230 ]
231 const short = [`numbers withheld (${age}${NB}old)`, 'run claude-accounts']
232 // the verdict and the age in words a 7-cell row holds whole: `numbers` / `hidden` / `(2h` / `old)`
233 const tiny = [`numbers hidden (${age}${NB}old)`]
234 return { grid: false, forms: wholeForms(wide ? [long, short, tiny] : [short, tiny], cells) }
235 }
236 if (ageS >= staleS) {
237 const long = [`⚠${NB}STALE by ${age}${NB}— the numbers below are last-known, not current.`, ' Live table: claude-accounts']
238 const short = [`⚠${NB}stale ${age}`, 'live: claude-accounts']
239 // Last resort, for a band whose board fits whole only under a one-row head (30-row pane: the
240 // 9-row dense board plus the 2-row short head is 11, one over): the age stays, the hint goes.
241 // The short form on one row where it fits (same words, one row fewer) goes before it.
242 const one = `⚠ stale ${age} · live: claude-accounts`
243 const forms = [...(wide ? [[...long, ''], long] : []), ...(cw(one) <= cells ? [[one]] : []), short,
244 [`⚠${NB}stale ${age}`]]
245 return { grid: true, forms: wholeForms(forms, cells) }
246 }
247 return { grid: true, forms: [[]] }
248}
249
250// The fullest form, as the hook prints it (a band with no width yet, and the tests' fixed text).
251export const bandHead = (ageS: number, staleS: number, hardS: number, cells = Infinity): { head: string[]; grid: boolean } => {
252 const { forms, grid } = bandHeads(ageS, staleS, hardS, cells)
253 return { grid, head: forms[0] ?? [] }
254}
255
256// What the band draws: a head and the board under it, together inside `maxRows`. Each head form
257// takes the layout pickFit gives it (the head's rows count against the board's, `headRows`), and
258// the pair is ranked: everything shown, over every ACCOUNT shown (the cut in the alerts, 2026-10-22:
259// a stale head pushed the last account's weekly row under the fold), over neither; a terse layout
260// under every labelled one on either of the first two, as in pickFit. Within a tier, the fuller
261// layout (ties: the fuller head, so the hook's sentence stays wherever the board can afford it);
262// within the last, the fewest rows in all.
263//
264// THE STALE TAG SHARES A ROW (2026-10-22). A stale head on a row of its own was the row a 30-row
265// pane's band did not have at 15-22 cells (next3 under `↓ 2 more`), and a 45-row one at 22 on the
266// fixture. So where no head on its own row keeps the board whole, the tag (`⚠ stale 6m`, else
267// `⚠ 6m`) may join a dense layout: at the end of its first lane row with room for it (the renderer
268// ships each lane row's cells, `lanes`, so nothing here measures a name), else, the full tag only,
269// in the blank lead of the row under the lanes, the legend's, two cells clear of its first word (a
270// bare `⚠ 6m` there, judged on screen at 20 cells, read as one of the legend's labels). It is
271// ranked with the heads above and wins only on a better tier or a fuller layout: where both tie,
272// the head keeps its own row and its words.
273const SGR = /\x1b\[[0-9;]*m/y
274const leadBlanks = (line: string) => line.replace(/\x1b\[[0-9;]*m/g, '').match(/^ */)?.[0].length ?? 0
275
276// `line` with its first `n` cells, blanks, given to `tag`; its colour sequences kept, after the tag
277const setLead = (line: string, tag: string, n: number) => {
278 let codes = ''
279 let i = 0
280 for (let skip = n; i < line.length;) {
281 SGR.lastIndex = i
282 const m = SGR.exec(line)
283 if (m) { codes += m[0]; i += m[0].length; continue }
284 if (skip === 0) break
285 skip--; i++
286 }
287 return tag + codes + line.slice(i)
288}
289
290const mergeTag = (v: Variant, tag: string, cells: number, lead: boolean): Variant | undefined => {
291 const lw = v.dense ? v.lanes : undefined
292 if (!lw || lw.length === 0 || lw.length > v.lines.length) return undefined
293 const t = cw(tag)
294 const lines = [...v.lines]
295 // `width` stays the layout's own: it ranks the layout (pickFit), and the row already fits `cells`
296 const r = lw.findIndex(w => w + 1 + t <= cells)
297 if (r >= 0) {
298 lines[r] = `${lines[r]}\x1b[0m ${tag}`
299 return { ...v, lines }
300 }
301 const k = lw.length
302 if (lead && k < lines.length && leadBlanks(lines[k]) >= t + 2) {
303 lines[k] = setLead(lines[k], tag, t)
304 return { ...v, lines }
305 }
306 return undefined
307}
308
309export const bandLayout = (ladder: Ladder, cells: number, maxRows: number | undefined,
310 ageS: number, staleS: number, hardS: number): { head: string[]; variant?: Variant } => {
311 const { forms, grid } = bandHeads(ageS, staleS, hardS, cells)
312 const room = maxRows && maxRows > 0 ? maxRows : Infinity
313 const cands: { head: string[]; variant?: Variant }[] = forms.map(head =>
314 ({ head, variant: grid ? pickFit(ladder, cells, maxRows, head.length) : undefined }))
315 if (grid && ageS >= staleS && ageS < hardS) {
316 const age = fmtAge(ageS)
317 for (const [tag, lead] of [[`⚠ stale ${age}`, true], [`⚠ ${age}`, false]] as const) {
318 const merged = ladder.variants.flatMap(v => (v.width <= cells ? [mergeTag(v, tag, cells, lead)] : []))
319 .filter((v): v is Variant => v !== undefined)
320 if (merged.length > 0) cands.push({ head: [], variant: pickFit({ ...ladder, variants: merged }, cells, maxRows, 0) })
321 }
322 }
323 let best: { head: string[]; variant?: Variant; tier: number; rows: number } | undefined
324 for (const { head, variant } of cands) {
325 const rows = head.length + (variant?.lines.length ?? 0)
326 // cut, the band shows one row fewer than `maxRows` (the cue takes the last); a terse layout
327 // ranks under every labelled one that shows its accounts, as in pickFit
328 const shown = rows <= room ? 2 : head.length + (variant ? valueRows(variant) : 0) <= room - 1 ? 1 : 0
329 const tier = shown === 0 ? 0 : variant?.terse ? shown : shown + 2
330 const better = !best || tier > best.tier || (tier === best.tier && (tier > 0
331 ? !!(variant && best.variant && fuller(variant, best.variant))
332 : rows < best.rows))
333 if (better) best = { head, variant, tier, rows }
334 }
335 const out = best ?? { head: [] }
336 return out.variant ? { head: out.head, variant: out.variant } : { head: out.head }
337}
338types/index.d.ts 37 lines1/** One layout of the readout: what `readout_lines(pane=cells)` drew, and its widest line's cells.
2 * `dense` (2026-10-22) marks the row-sparing family (`readout_lines(dense=True)`): the picks and
3 * every account's 5h/weekly use and reset, on as few rows as the width allows. Absent means not
4 * dense, so a ladder from an older producer reads as one full family. `values` (2026-10-22): how
5 * many lines run through the last account row, where that is fewer than all of them (the rest are
6 * alerts and footers); absent means every line counts. `terse` (2026-10-22): a dense layout
7 * without the labels that cost rows (the column form's `reset` key row, a spelled-out `← you`),
8 * shipped right after the labelled one at the same `cells`, only where the two differ; the band
9 * draws it only where the labelled one would not fit its height. `lanes` (2026-10-22, dense only):
10 * the display cells of each lane row, the rows a dense layout opens with, so the band can set a
11 * short stale tag at the end of one (or in the legend's blank lead under them) instead of spending
12 * a row on it; absent means the band never merges into this layout. */
13export type Variant = {
14 cells: number; width: number; lines: string[]; dense?: boolean; values?: number; terse?: boolean
15 lanes?: number[]
16}
17
18/** The renderer's cell budget per pane width: `cells[columns - from]` (bin/claude-accounts pane_width). */
19export type Budget = { from: number; cells: number[] }
20
21/** `claude-accounts --readout`'s width ladder (bin/claude-accounts pane_ladder), narrowest first. */
22export type Ladder = { v: 1; written_ms: number; gutter: number; budget: Budget; variants: Variant[] }
23
24/** The SessionStart board as the band draws it: the ladder hooks/accounts-board.sh handed over,
25 * its board's mtime, and the hook's two staleness bands (CC_BOARD_STALE_S, CC_BOARD_HARD_S).
26 * `raisedMs` is when the band went up and `graceS` (CC_BOARD_BAND_GRACE_S) how long after it a
27 * prompt.submit is taken for a brief submitted at launch, which leaves the band standing. */
28export type BoardBand = {
29 ladder: Ladder; mtimeMs: number; staleS: number; hardS: number; raisedMs: number; graceS: number
30}
31
32declare module 'claude-code' {
33 interface PluginState {
34 'accounts-readout': { ladders: StateFamily<Ladder>; board: BoardBand | null }
35 }
36}
37