SLOPSHOPPER

accounts-readout

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

newbandrowsguardprompttimer
★ 1v0.1.0no licenseupdated 2026-10-23renchris/claude-infrastructure/mods/accounts-readout
A shopper browsing a rack in a slop shop
README

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.

tests accounts hooks sessions

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 doesThe job it takes off you
1The 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
2The 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
3You 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

1. The whole system deploys from git

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 &lt;--&gt;|"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 repo

Editing 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.

Every trunk tree is proven in the background, and a red one is bisected

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.

Landed is not live until the running processes load it

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.

A Claude Code update cannot break a running session

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.


2. The sessions run each other

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>

A session fires a peer and hears back from it

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.

TouchpointCommandWhat happens
Fire/handoff → handoff-fire.sh --split-rightSplits 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.
Recyclehandoff-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.
Switchcc-lr switchMoves 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.
Messagecc-notify · --notify-back · cc-await-pingA 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.
Retirehandoff-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/&lt;uuid&gt;.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:&lt;uuid&gt;"]
    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:

  • A pane id goes stale the moment the pane recycles. Forensics found 570 pages looping into one dead inbox for three days. Automated senders now address a role (such as 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.
  • Writing to a dead inbox is not delivering. The recipient's liveness decides the verdict, and a reroute never upgrades it: cc-notify reports "mailbox only", and cc-inbox-guard fails loud on mail that nothing will read.
  • Reading a message is not acting on it. The .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.

Every session works on its own branch and account, and lands through one lock

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>

  • A bare 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.
  • Four quota pools mean routing decides what gets wasted. Weekly quota still unspent when it resets is gone, and a burst of sessions on one account exhausts its 5-hour window. 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.
  • When many sessions land at once, a full test run inside each land starves them all. Fitted to 55 real runs, a model put the chance of a green land at ~2.3% under fleet load, and one branch failed 37 times in a row on a tree that was never red. So the heavy verdict moved out of the land path (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.
  • A commit count says "landed" for work that was silently dropped. That happened to a 5-file commit on 2026-07-11. Landing is therefore verified by content (land-verify.sh): every path is present on trunk and its diff is empty.

Any command a session runs can be refused

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

Source 3 files
hooks/register.tsx 234 lines
1import { 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
234
hooks/ladder.ts 338 lines
1import 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}
338
types/index.d.ts 37 lines
1/** 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