Status band, Log pane and Progress pane for Claude Code: draws the session's cache, context, rate and model figures above the prompt, and the event log and…

Band draws what a Claude Code session shows around the prompt: the status band above the prompt (prompt-cache countdown, model, context use, every rate-limit window, the session's cost, the task digest and the Compact / Clear / Progress / Log buttons), the Progress pane with the task checklist and the event log pane. It draws and nothing else. The figures come from the session itself; the checklist and most log lines come from the other plugins in this marketplace, which publish them through $.state: base (or fnd, on an install that has not moved to base yet) publishes the resolved task and its own events, slim publishes its compression events, and the team plugins on base (fe, qa, be, pm) publish their own start, install and doctor lines. Band reads them; it never writes another plugin's state.
Current release: band v0.4.0.
scripts/install.sh --plugin band exits 2.claude -p) the hooks still run and /band-log and /band-progress answer as text./plugin install band@domaine
Update fnd first: an fnd release older than the one that yields to band (0.134.0 and earlier) still draws its own band, and only one plugin's band can show above the prompt, so load order decides which. A current fnd sees band loaded and stops drawing (see Next to fnd).
fnd's FND_BAND_COST does not carry over: set BAND_COST=1 in ~/.claude/settings.json → env to keep the cost segment. Which plugins to install for what is the matrix in the root README.
One row, most important first. 170 columns, context at 47 %, an account reporting three windows, base resolving a task:
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
cache 42m │ fable-5-1 │ ctx 47% │ 5h 61% · 7d 34% · 7d·fable 12% │ cost $12.40 │ ELC-1591 3/5 ▶ Preview themes for QA review │ [ Compact ] [ Clear ] [ Progress ] [ Log ]
120 columns, same session. The row is 172 cells, so the Log button goes first, then Clear, then the digest:
cache 42m │ fable-5-1 │ ctx 47% │ 5h 61% · 7d 34% · 7d·fable 12% │ cost $12.40 │ [ Compact ] [ Progress ]
60 columns. The rate windows go next, the least full one first, then the model:
cache 42m │ ctx 47% │ [ Compact ] [ Progress ]
The full drop order is: the Log button (/band-log still opens the pane), the Clear button (/clear still works), the digest, the cost, then the rate windows beyond the fullest (least full first), then the last window, then the model, then the Progress button. Cache, ctx and Compact are never dropped. The row's text truncates as a backstop, so the band never takes a second row.
| Segment | Shows | Rule |
|---|---|---|
| cache | cache 42m, <1m, cache cold, cache —, cache ● | Minutes left of the prompt cache: the last main-thread response plus the TTL. ● while a turn runs; — before the first response and after /clear or resume; cold once the TTL has passed, after a compaction, or after a /model switch that forfeits the cache (another model, or the host reports it cold). A resumed session takes its state from the time since the last response. Hidden while any rate window is at or past 100 %: in overage the TTL is unknown. It is an estimate. The host reports no cache state, only the events it is derived from. |
| model | fable-5-1 | The model id as the session reports it, without the claude- prefix every id carries. A /model switch updates it from the switch event itself; every measurement re-reads it, so a switch the event missed shows by the next response. On the terminal it is a picker: the segment reads fable-5-1 ▾ and is a button (hotkey m while the band holds the keyboard: ctrl+x tab, or a click). Pressed, the row folds its figures away and holds the models alone, in place: model fable-5-1 opus-5-5 sonnet-5-5 haiku-4-5-20251001 (plus the session's own id first when it is none of these, say a pinned [1m] id), the current one bright and the rest dim, each with its first letter as hotkey (f, o, s, h). Tab or a letter picks one; the pick runs /model <id> as typed, toasts its output and folds the row back, which then follows the switch event. The current model only folds the row; a turn or /clear folds it too. The band never grows: its region clips anything drawn outside its own two rows, so no list can pop over the transcript. The desktop app has its own model menu, so there the segment stays text. |
| ctx | ctx 47%, ctx — | Context-window use. It is — on a fresh session until the first response. Right after a compaction it shows the engine's own count of what was kept over the window (ctx 3%), or — when the engine reported no count; that count holds until a response reports a measured fill, as a reading with no fill (only the window) keeps the last one. A compaction reaches the band two ways, the session.compact chain and the engine's own PostCompact report (the settings-hook event, which arrives even when the chain skips the mod, as it did for a Compact press on Claude Code 2.1.289); reports within 30 s of each other are one compaction. Mid-turn it refreshes on a 30 s tick. |
| rates | 5h 61% · 7d 34% · 7d·fable 12% | Every window the API reports, in its order, separated by a dim ·: five_hour → 5h, seven_day → 7d, spend_limit → $; an unknown kind keeps a shortened raw name (7d·fable); past 100 % reads >100%. Empty off a subscription and before the first reading. |
| cost | cost $12.40 | Opt-in: drawn only with BAND_COST=1 in the session's environment. What the session has cost at API prices, as /cost totals it (usage().cost.usd). A subscription is not billed per request, so there it is a measure of work, not a bill. Hidden while it is zero and where the host keeps no ledger. |
| digest | ELC-1591 3/5 ▶ Preview themes | The task base (or fnd) resolved, same on the terminal and the desktop: work id · checked/total rows of the workspace's progress.md · the current row as the publisher writes it (cut to 28 characters); ELC-1591 ✓ 5/5 when all are done; the bare id (ELC-1588) while the workspace has no progress.md yet, or while the ticket you named has no workspace at all. Hidden while the Progress pane is open, when the publisher resolves nothing, and without base or fnd. |
| Compact | [ Compact ], c: Compact | Always drawn first and always pressable, so the other buttons never shift: before the first reading, while a turn runs and at any context. From 80 % between turns it switches to the accent color. While the band holds the keyboard it reads c: Compact. A press runs /compact and toasts the result (compacted 412,000 → 38,000 tokens, or why it was skipped or refused). Where the engine refuses compaction from a plugin (a headless / SDK session such as the desktop app), the press runs the /compact slash command as if typed instead and toasts its output. Pressed while a turn runs it only toasts turn is running — press Compact again when it ends; nothing is queued. |
| Clear | [ Clear ], x: Clear | Runs /clear behind the engine's own Yes/No dialog (Clear the conversation?), always: the dialog takes the keyboard, so a stray click or hotkey never clears, and a dismissed dialog is a No. Toasts the command's output. Pressed while a turn runs it only toasts turn is running — press Clear again when it ends. Dim at rest, x: Clear while the band holds the keyboard. |
| Progress | [ Progress ], p: Progress | Drawn only while base or fnd resolves a task (its base.progress or fnd.progress carries a work id). Opens or closes the Progress pane; dim at rest, p: Progress while the band holds the keyboard |
| Log | [ Log ], l: Log | Drawn only while some event list (band's, base's, fnd's, slim's or a team plugin's: fe's, qa's, be's, pm's) holds a line. Opens or closes the event log pane; dim at rest, l: Log while the band holds the keyboard |
Look. On a terminal a dim rule (────) separates the band from the transcript above it; the desktop frames its panel itself, so no rule is drawn there. The desktop draws the buttons on a second row under the figures, left-aligned, with a row of air between the two and padding around the panel: its native buttons are tall, and in the figures' row they squashed it and sat far right. The terminal keeps one row, as its height is the scarce side there. Each figure is a dim label and a bold value (cache dim, 42m bold; the same for ctx and each rate window), the model id is plain (on the terminal with the picker's ▾ mark), the digest's work id is bold and the separators are dim.
Colors. ctx is green (the theme's success) up to 30 %; the rate windows are plain there. Both use the theme's warning color above 30 % and the alarm look from 80 %. The cache is plain at 10 min or more, warning below 10 min and the alarm below 2 min (a 5 min TTL scales both: warning below 2 min, the alarm below 24 s). Only theme keys are used, so the band follows light, dark and high-contrast themes. The alarm look is warning + bold + inverse until a dedicated error key is proven to draw on every theme.
Desktop and hover. In the desktop app's Code tab every segment carries a glyph instead of a word (⏱ 42m │ 🤖 fable-5-1 │ 🧠 47% │ ⏳ 5h 61% · 7d 34% │ 💰 $12.40 │ 📋 ELC-1591 3/5 ▶ …), and Compact / Clear / Progress / Log are native buttons. The desktop draws proportional text, so the width model above does not apply there: nothing is dropped, the row clips at the panel's edge. When the pointer rests on the cache, ctx, a rate window or the cost, a one-line card appears. This is meant for desktop and for terminals that pass the pointer through (kitty, Ghostty, iTerm2, WezTerm; tmux passes none). The cards read: prompt cache: ~42 min left (estimate: last response + 1 h TTL), context: 47% of 200,000 tokens, 94,000 used, 5h window: 61% used, resets in 2h 05m, session cost: $12.40 at API prices, as /cost counts it (a subscription is not billed per request). The glyph labels, the native buttons and hover are still a live check (desktop and terminal paint).
Toasts. When a rate window first reaches 90 %, one toast shows that window's card for 8 s (5h window: 91% used, resets in 1h 05m). It re-arms once every window is back under the line, and after /clear. The savings toasts are fnd's and slim's, not band's (FND_SLIM_TOAST, SLIM_TOAST). Toasts sit at the transcript's top-right in fullscreen, or on the notification line under the prompt otherwise. Several toasts stack; a click takes one off and the pointer over it holds it (there is no close button). They never touch the transcript or what the model reads. They keep showing while a pane is open (a pane is not a dialog and does not hold toasts).
Hotkeys. c, x, p, l and m work only while the band holds the keyboard: after ctrl+x tab or a click on the band. They never fire from the composer, so typing a c is just a c. The letters are drawn only then too (c: Compact x: Clear p: Progress l: Log): at rest the buttons read [ Compact ] [ Clear ] [ Progress ] [ Log ], so the band never suggests a key the composer would swallow. The letters go away again on a press, when a turn starts and on /clear (there is no focus-out event, so Esc alone leaves them until the next of those). On a terminal the buttons are mainly clicked (pointer-capable terminals) or reached as ctrl+x tab then the letter, and Esc gives the keyboard back to the prompt. To focus the band with a single chord, rebind abovePrompt:focus (default ctrl+x tab) in ~/.claude/keybindings.json, then press the letter. On desktop they are ordinary buttons with no hotkey at all (a desktop draws a hotkey as a badge on its native button, and a click is the way there). ctrl+x ctrl+a collapses the band; while it is collapsed the engine shows one dim line above the prompt, plugin panel hidden · ctrl+x ctrl+a or click to show, and that chord or a click on the line brings it back. The collapsed state is the engine's and persists across reloads; the plugin cannot and does not reopen the band by itself.
Debug. /band-debug prints the raw figures behind the band: band.info, the session's usage() answer, the cache state, the usage atom, the task the publisher resolved (work id and branch, or null), the publisher (base, fnd or none), the session root and the last render's surface and measured columns. Paste its output when a segment looks wrong on some surface.
p: Progress, or /band-progress, opens a pane with the task base or fnd resolved: docked beside the transcript in a wide fullscreen terminal, inline otherwise (the surface decides). Esc or a second press closes it.
ELC-1591 · feature/ELC-1591-preview-themes · 3/5
✓ Read the ticket
✓ Plan approved
✓ Header markup
▶ Preview themes for QA review
☐ Steps to Test
- preview theme 141234567890 created for QA
- decision: keep the old toggle behind a setting
- open: confirm the empty-state copy with design
The header is the work id · branch · checked/total, in bold. Below it come every progress.md row (✓ done dimmed, ▶ the digest's current row in bold, ◌ unchecked rows above ▶ dimmed — they wait on someone, not the queue — ☐ the rest) and the last three - lines of notes.md, dimmed. A workspace without progress.md shows its id, the branch, one dim line no progress.md yet — /base:save-task-context and the notes tail. A ticket you named that has no workspace shows its id, the branch and no task workspace — /base:save-task-context. The hint names the publisher's own skill: under fnd it reads /fnd:save-task-context.
base.progress when it holds a value, else fnd.progress. base and fnd never run together, so one of them publishes; with both loaded, base's value wins, even one that resolves no task.progress.md) publishes the result and band draws it. base's order is in base's README, fnd's in the root README's Progress pane section./base-progress <KEY> (fnd: /fnd-progress <KEY>) pins a task, /base-progress - (fnd: /fnd-progress -) clears the pin. /band-progress takes no argument./band-progress then answers No task checklist: none of the loaded plugins publishes one (with base: /base-progress <KEY> pins one; with fnd: /fnd-progress <KEY>). and opens nothing. If the task goes away while the pane is open, the pane reads no task checklist..claude/tasks/, its 30 s tick, a branch or directory change, a pin).progress pane not placed: <reason> and answers with the same reason.claude -p), /band-progress answers with the checklist as text: the header, one ✓ / ▶ / ◌ / ☐ line per row, then the footer lines.Toasts flash and go. l: Log, or /band-log, opens a pane that keeps them: what band, base, fnd, slim and the team plugins (fe, qa, be, pm) did or noticed, one line each, oldest first and newest last. Esc, a second press or the engine's close mark closes it. The Progress pane and the log pane can be open at once; the engine shows one and keeps the other as a tab. Pressing the button or running the command of the pane behind the tab brings that pane forward instead of closing it.
14:02 band session start · band 0.2.0
14:02 base start base 0.1.0
14:05 base workspace ELC-1588
14:21 fnd fnd-slim getJiraIssue: compressed 118,203 B → 29,412 B (−75.1%)
14:22 slim slim Bash: compressed 120 KB → 11 KB (−91%) · json
14:33 base guard Bash: --no-verify
14:40 band compact manual 412k → 38k
The lists are merged by time; lines written in the same millisecond keep the order band → base → fnd → slim → fe → qa → be → pm. The plugin column, between the time and the kind, names the list a line came from (band, base, fnd, slim, fe, qa, be, pm, padded to 5), so a kind several plugins share (guard, workspace, start, install, doctor) still says whose it is. The time is local HH:MM; time, plugin and kind are dim. A line too long for the pane continues on the next row, under its own text column, on the terminal and the desktop alike. When the pane is shorter than the log, its first line reads … 12 earlier and the newest lines fill the rest, wrapped rows counted. With no line anywhere the pane reads no events yet. A surface that cannot place the pane toasts log pane not placed: <reason>. Where nothing draws a pane — a cloud session (the browser at claude.ai/code, the Desktop app's cloud sessions), the VS Code chat panel, claude -p — /band-log answers with the log as text instead, one line per event, and the Log button is not there to press.
| Kind | Writer | Written when | Text |
|---|---|---|---|
session | band | band's module starts at launch, on a resume or fork, and on /clear; a reload of the module in the same process (a /config change) writes none | start · band <version> (the version names the drawer: fnd's own line reads start), resume, fork, clear (a resume can log both start and resume) |
model | band | A /model switch to another model | The full model id, claude-opus-5-5 |
compact | band | A compaction of the main thread | The trigger (manual, auto, plugin for the Compact button) and the tokens before → after when the engine reports them. One line per compaction, whichever of its two reports (the session.compact chain, the PostCompact event) arrives first |
rate | band | A rate window first reaches 90 % | The alarm toast's text, 5h window: 92% used, resets in 1h 05m |
start | base, slim, fe, qa, be, pm | The plugin's session start, once per session id | <plugin> <version>: base <version>, slim <version>, qa <version> |
install | base, fe, qa, be, pm | base: slim is missing, or fnd is loaded beside base. A team plugin: base is not loaded | The install pointer, or the advice to uninstall fnd; a team plugin's reads needs the base plugin — claude plugin install base@domaine |
profile | fe | fe decides the project profile | The profile word and how it was decided, theme (project-profile.sh) |
workspace | base, fnd | The task the plugin resolved differs from the last one logged | The work id, or none |
refuse | base | base refuses a reader spawn because slim is missing | <agent>: slim is not loaded |
title | base | base titles the session after the ticket key | The title |
doctor | base, fe, qa, be, pm | The plugin's doctor runs (/base-doctor, /fe-doctor, /qa-doctor, /be-doctor, /pm-doctor) | The run's counts |
fnd-slim | fnd | fnd's own MCP slimming finds a savings figure | As fnd writes it; the kind reads slim while slim has written no line, fnd-slim once it has |
prompt | fnd | fnd rewrites a pasted JSON prompt | The toast's figure |
guard | base, fnd | A guard of the plugin refuses a tool call | The tool and the first line of the reason |
slim | slim | slim compresses or stubs a result | <tool>: compressed … · <engine>, a subagent's call prefixed with its agent type |
lookup | slim | slim's lookup tool answers a question | lookup: <question…> · <model> · <tokens> |
view | slim | slim's view tool returns a file, URL or command output | view <source>: <size> → <size> (<saving>) · <engine>, or cached, narrowed by jq, refused (<reason>); a subagent's call prefixed with its agent type |
base or fnd lines of kind session, model, compact or rate are not shown: those kinds are band's, and an fnd that still writes them (a release that does not yield, or the moment before fnd sees band loaded) would show each one twice.
Each publisher keeps its own list of at most 200 lines. Past 200, band, base and the team plugins drop their oldest line; fnd drops its oldest slim or prompt line first. The pane reads the session's state, so a new launch starts it empty; the record that outlives the session is each plugin's own file, below. /clear keeps the lines and adds session clear where the conversation restarted. Each plugin gates its own lines, in the pane and in its file: BAND_EVENT_LOG=0, BASE_EVENT_LOG=0, FND_EVENT_LOG=0, SLIM_EVENT_LOG=0, and a team plugin's FE_EVENT_LOG=0, QA_EVENT_LOG=0, BE_EVENT_LOG=0, PM_EVENT_LOG=0. Toasts are unchanged by any of them.
Every Domaine plugin (slim, band, base, fe, qa, be, pm) writes the lines it publishes itself to its own file, so the origin of a line is on the line, written by that plugin, not inferred from the pane:
$HOME/.claude/domaine/log/<session-id>/<plugin>.jsonl (base.jsonl, band.jsonl, slim.jsonl, and a team plugin's own, such as qa.jsonl). DOMAINE_LOG_DIR (an absolute directory) replaces $HOME/.claude/domaine/log; the <session-id>/ folder is still made under it. With neither (a cloud session) no file is written. Never under the project.{"ts":"2026-10-08T12:34:56.789Z","plugin":"band","version":"0.2.0","session":"<id>","kind":"model","agent":"main","text":"claude-opus-5-5"}. ts is the event's time in UTC, plugin and version the writer's own name and release, agent the subagent type that caused the line or main, kind and text the line as the pane shows it.start / <plugin> <version>, also under the new id a /clear opens, where no session start runs. A session whose lines already hold a start line gets no second one: a module reload or a resume in a fresh process goes on from the file.session, model, compact and rate, agent always main. The first line of a session is start / band <version> (the pane's session / start · band <version>), also in the file a /clear or a resume opens under the new session id, where no session start runs. session clear closes the old session's file; it is written only while the exit's short bound has time left, and a failure there never toasts mid-/clear./config change) picks up the file of the same session and goes on. A write that fails never reaches the hook; the first failure in a session toasts band: event log not written: <reason>.BAND_EVENT_LOG=0 stops band's file and its pane lines alike.hooks/mods/register.tsx 21 lines1// The band hooks module (Claude Code only): the status band, its figures, the Log and Progress panes and the session
2// marker. band draws and writes only its own atoms; it reads fnd.* and slim.* and never writes them.
3// Each feature file declares its own atoms and keeps its `$` code to itself: the validator follows `$` only within one file.
4import type { Register } from 'claude-code'
5import { registerBand } from './band.tsx'
6import { registerChecklist } from './checklist.tsx'
7import { registerInfo } from './info.ts'
8import { registerLog } from './log.tsx'
9import { registerMarker } from './marker.ts'
10import { registerUsage } from './usage.ts'
11
12export const register: Register = (on, options) => {
13 // First, so the marker is written before every other prompt.submit hook runs.
14 registerMarker(on, options)
15 registerInfo(on, options)
16 registerUsage(on, options)
17 registerBand(on, options)
18 registerChecklist(on)
19 registerLog(on)
20}
21hooks/mods/band.tsx 337 lines1// Status band: the AbovePrompt row drawn from the atoms, the Compact and Clear presses and the terminal's model
2// picker, which unfolds into the row itself: the band region clips anything drawn outside its own rows, so no
3// list can pop over the transcript. A desktop draws the buttons on a second row under the figures.
4// Render only reads: band's atoms (usage.ts and checklist.tsx write them), base's or fnd's task snapshot for the
5// digest and the Progress button, every publisher's event list for the Log button. The Progress and Log presses are
6// answered by the `ui.press` hooks on elements `progress` (checklist.tsx) and `log` (log.tsx).
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, On, PluginOptions, RenderNode } from 'claude-code'
9import type { BandEvent, BandRate, ForeignEvent } from '../../types'
10import {
11 CACHE_INIT,
12 CTX_PROPS,
13 GLYPH,
14 LEVEL_PROPS,
15 MODEL_MARK,
16 RULE,
17 SEP,
18 USAGE_INIT,
19 bandSegs,
20 cacheCard,
21 cells,
22 cacheView,
23 compactToast,
24 costCard,
25 ctxCard,
26 digestOf,
27 glyphText,
28 layout,
29 modelOptions,
30 pctLevel,
31 pickProgress,
32 rateCard,
33 rateText,
34 splitLabel,
35 toChecklist,
36} from './lib.ts'
37import { merged, take } from './events.ts'
38
39const usage = atom({ plugin: 'band', key: 'usage' } as const, USAGE_INIT)
40const model = atom({ plugin: 'band', key: 'model' } as const, null)
41const cache = atom({ plugin: 'band', key: 'cache' } as const, CACHE_INIT)
42const tick = atom({ plugin: 'band', key: 'tick' } as const, 0)
43const paneShown = atom({ plugin: 'band', key: 'paneShown' } as const, false)
44const bandFocused = atom({ plugin: 'band', key: 'bandFocused' } as const, false)
45const modelPicker = atom({ plugin: 'band', key: 'modelPicker' } as const, false)
46const events = atom({ plugin: 'band', key: 'events' } as const, [] as BandEvent[])
47const baseProgress = atom({ plugin: 'base', key: 'progress' } as const, null)
48const fndProgress = atom({ plugin: 'fnd', key: 'progress' } as const, null)
49const baseEvents = atom({ plugin: 'base', key: 'events' } as const, [] as ForeignEvent[])
50const fndEvents = atom({ plugin: 'fnd', key: 'events' } as const, [] as ForeignEvent[])
51const slimEvents = atom({ plugin: 'slim', key: 'events' } as const, [] as ForeignEvent[])
52const feEvents = atom({ plugin: 'fe', key: 'events' } as const, [] as ForeignEvent[])
53const qaEvents = atom({ plugin: 'qa', key: 'events' } as const, [] as ForeignEvent[])
54const beEvents = atom({ plugin: 'be', key: 'events' } as const, [] as ForeignEvent[])
55const pmEvents = atom({ plugin: 'pm', key: 'events' } as const, [] as ForeignEvent[])
56
57type $ = EngineInterface
58
59/** Main-loop turn in flight: turn events flip it, each band draw syncs it; usage.ts clears it, as the engine allows one unmatched turn.complete hook. */
60export const turn = { running: false }
61
62function pressCompact($: $): void {
63 if (turn.running) {
64 $.ui.toast('turn is running — press Compact again when it ends')
65 return
66 }
67 const refused = (err: unknown) => $.ui.toast(`compact refused: ${err instanceof Error ? err.message : String(err)}`)
68 // A headless (SDK, desktop app) session refuses the op but still runs a typed /compact.
69 $.session.compact().then(
70 r => $.ui.toast(compactToast(r)),
71 () => $.command.run({ command: 'compact' }).then(r => $.ui.toast(r.text || 'compacted'), refused),
72 )
73}
74
75const CLEAR_QUESTION = 'Clear the conversation?'
76const CLEAR_YES = 'Yes'
77
78/** Always behind the engine's own Yes/No dialog: it takes the keyboard, so a stray click or hotkey never clears. */
79function pressClear($: $): void {
80 if (turn.running) {
81 $.ui.toast('turn is running — press Clear again when it ends')
82 return
83 }
84 const refused = (err: unknown) => $.ui.toast(`clear refused: ${err instanceof Error ? err.message : String(err)}`)
85 // A dismissed dialog rejects: that is a No.
86 $.ui.ask(CLEAR_QUESTION, [CLEAR_YES, 'No']).then(
87 answer => {
88 if (answer !== CLEAR_YES) return
89 $.command.run({ command: 'clear' }).then(r => $.ui.toast(r.text || 'cleared'), refused)
90 },
91 () => undefined,
92 )
93}
94
95/** A pick folds the picker and runs `/model <id>` as typed; the switch event then moves the segment. The current id only folds. */
96function pickModel($: $, id: string, current: string): void {
97 void setPicker($, false)
98 if (id === current) return
99 $.command.run({ command: 'model', args: id }).then(
100 r => $.ui.toast(r.text || `model ${id}`),
101 err => $.ui.toast(`model refused: ${err instanceof Error ? err.message : String(err)}`),
102 )
103}
104
105/** The surface the band last drew on; a desktop has no hotkey letters, so focus moves must not redraw it. */
106let drawnOn: string = 'terminal'
107
108/** The last render's surface and measured props, for /band-debug. */
109export const lastRender: { surface: string | null; bodyColumns: number | undefined; maxRows: number | undefined } = {
110 surface: null,
111 bodyColumns: undefined,
112 maxRows: undefined,
113}
114
115/** Guarded write: a redraw between a click's focus-in and its press would swallow the press. */
116async function setFocused($: $, to: boolean): Promise<void> {
117 if (drawnOn !== 'desktop' && (await read($, bandFocused)) !== to) await update($, bandFocused, () => to)
118}
119
120/** Guarded write, as setFocused; the desktop never unfolds. */
121async function setPicker($: $, to: boolean): Promise<void> {
122 if (drawnOn !== 'desktop' && (await read($, modelPicker)) !== to) await update($, modelPicker, () => to)
123}
124
125export function registerBand(on: On, options: PluginOptions): void {
126 // Hotkey letters are drawn only while the band holds the keyboard. A focus-in sets the flag; a
127 // press, a turn or /clear clears it, as there is no focus-out event. The hotkeys stay armed.
128 on('ui.focus', { component: 'AbovePrompt' }, async ($, e, next) => {
129 if (options.disabled === true) return next(e)
130 await setFocused($, true)
131 return next(e)
132 })
133 // Unfolding the model picker keeps the keyboard on the band: its models take hotkey letters of their own.
134 on('ui.press', { plugin: 'band' }, async ($, e, next) => {
135 if (e.element !== 'model') await setFocused($, false)
136 return next(e)
137 })
138 on('turn.start', async ($, e, next) => {
139 turn.running = true
140 await setFocused($, false)
141 await setPicker($, false)
142 return next(e)
143 })
144 on('session.end', { reason: 'clear' }, async ($, e, next) => {
145 turn.running = false
146 await setFocused($, false)
147 await setPicker($, false)
148 return next(e)
149 })
150
151 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
152 // The engine's own truth: every draw re-aligns a flag a missed turn.complete or a hot reload left wrong.
153 turn.running = e.props.isWorking
154 if (options.disabled === true || e.props.hasSurvey) return next(e)
155 const u = await read($, usage)
156 const m = await read($, model)
157 const c = await read($, cache)
158 const now = await read($, tick)
159 const isPaneShown = await read($, paneShown)
160 const focused = await read($, bandFocused)
161 const unfolded = await read($, modelPicker)
162 if (u.ctxPct === null && u.rates.length === 0 && m === null && c.anchorMs === null) return next(e)
163
164 const isWorking = e.props.isWorking
165 const snapshot = pickProgress(await read($, baseProgress), await read($, fndProgress))?.snapshot ?? null
166 const digest = !isPaneShown ? digestOf(snapshot) : null
167 const hasChecklist = toChecklist(snapshot) !== null
168 // Short-circuit: while band's own list holds a line the foreign lists are not read, so their writes do not redraw the band.
169 const hasEvents =
170 take(await read($, events)).length > 0 ||
171 merged([], await read($, baseEvents), await read($, fndEvents), await read($, slimEvents), {
172 fe: await read($, feEvents),
173 qa: await read($, qaEvents),
174 be: await read($, beEvents),
175 pm: await read($, pmEvents),
176 }).length > 0
177 const isDesktop = e.surface === 'desktop'
178 drawnOn = e.surface
179 lastRender.surface = e.surface
180 lastRender.bodyColumns = e.props.bodyColumns
181 lastRender.maxRows = e.props.maxRows
182 // A desktop draws proportional text: its bodyColumns do not measure the row, so nothing is dropped there.
183 const segs = layout(bandSegs({ usage: u, model: m, cache: c, nowMs: now, isWorking, digest, hasChecklist, hasEvents }), isDesktop ? undefined : e.props.bodyColumns)
184 const { Box, Text, Button } = $.ui.resolve(e)
185
186 const label = (text: string) => (isDesktop ? glyphText(text) : text)
187 // Dim label, bold value (`cache` dim, `42m` bold); a desktop glyph label stays at full strength.
188 const dimLabel = isDesktop ? {} : { dimColor: true }
189 const labeled = (text: string, labelProps: Record<string, unknown>, valueProps: Record<string, unknown>): RenderNode => {
190 const [l, v] = splitLabel(text)
191 return (
192 <Box flexDirection="row">
193 <Text {...labelProps} wrap="truncate-end">
194 {v ? `${l} ` : l}
195 </Text>
196 {v ? (
197 <Text {...valueProps} wrap="truncate-end">
198 {v}
199 </Text>
200 ) : null}
201 </Box>
202 )
203 }
204 const cardMax = e.props.bodyColumns && e.props.bodyColumns > 0 ? e.props.bodyColumns : Infinity
205 // A keyed Box is a hover scope; its hidden child is the card. A surface without a pointer never reveals it.
206 // The card gets its own width: an absolute Box would otherwise shrink to its segment. The terminal clips
207 // it to the segment's columns, so there it pops up a row above, over the rule; the desktop lays it over.
208 const hoverable = (key: string, body: RenderNode, card: string): RenderNode => (
209 <Box key={key}>
210 {body}
211 <Box
212 position="absolute"
213 top={isDesktop ? 0 : -1}
214 left={0}
215 width={Math.min(cells(card), cardMax)}
216 display="none"
217 hover={{ display: 'flex' }}
218 >
219 <Text inverse wrap="truncate-end">
220 {card}
221 </Text>
222 </Box>
223 </Box>
224 )
225 const rate = (r: BandRate, i: number): RenderNode[] => [
226 ...(i > 0 ? [<Text dimColor> · </Text>] : []),
227 hoverable(`seg-rate-${r.kind}`, labeled(rateText(r), dimLabel, { bold: true, ...LEVEL_PROPS[pctLevel(r.pct)] }), rateCard(r, now)),
228 ]
229
230 // A desktop draws a hotkey as a badge on its native button, and its buttons are clicked: no hotkeys there.
231 const letters = focused && !isDesktop ? { plain: true as const } : null
232 const hot = (k: string) => (isDesktop ? {} : { hotkey: k })
233
234 // Unfolded, the row holds the models alone: four names plus letters outgrow a row that also holds the figures.
235 // The current one is at full strength and only folds; no Esc, as the engine raises no focus-out.
236 if (unfolded && !isDesktop && m !== null) {
237 const options = modelOptions(m)
238 const row: RenderNode[] = [<Text dimColor>model </Text>]
239 options.forEach((o, i) => {
240 if (i > 0) row.push(<Text>{' '}</Text>)
241 row.push(
242 <Button
243 key={`model:${o.value}`}
244 label={o.label}
245 plain
246 {...(letters && o.hotkey ? { hotkey: o.hotkey } : {})}
247 {...(o.value === m ? {} : { dimColor: true })}
248 onPress={() => pickModel($, o.value, m)}
249 />,
250 )
251 })
252 const ruleCols = e.props.bodyColumns && e.props.bodyColumns > 0 ? Math.min(e.props.bodyColumns, 400) : 80
253 return (
254 <Box flexDirection="column">
255 <Text dimColor>{RULE.repeat(ruleCols)}</Text>
256 <Box flexDirection="row">{row}</Box>
257 </Box>
258 )
259 }
260
261 const groups: RenderNode[][] = []
262 if (segs.cache !== null) {
263 const level = LEVEL_PROPS[cacheView(c, now, isWorking).level]
264 groups.push([hoverable('seg-cache', labeled(label(segs.cache), dimLabel, { bold: true, ...level }), cacheCard(c, now))])
265 }
266 // The terminal has no model menu of its own in reach, so its segment is the picker's button; the desktop app has one.
267 if (segs.model !== null && m !== null) {
268 groups.push([
269 isDesktop ? (
270 <Text wrap="truncate-end">{`${GLYPH.model} ${segs.model}`}</Text>
271 ) : (
272 <Button key="model" label={`${segs.model}${MODEL_MARK}`} plain {...(letters ? { hotkey: 'm' } : {})} onPress={() => setPicker($, true)} />
273 ),
274 ])
275 }
276 const ctxLevel = u.ctxPct === null ? {} : CTX_PROPS[pctLevel(u.ctxPct)]
277 groups.push([hoverable('seg-ctx', labeled(label(segs.ctx), dimLabel, { bold: true, ...ctxLevel }), ctxCard(u))])
278 if (segs.rates.length) {
279 groups.push([...(isDesktop ? [<Text>{`${GLYPH.rates} `}</Text>] : []), ...segs.rates.flatMap(rate)])
280 }
281 if (segs.cost !== null && u.costUsd !== null) {
282 groups.push([hoverable('seg-cost', labeled(label(segs.cost), dimLabel, { bold: true }), costCard(u.costUsd))])
283 }
284 if (segs.digest !== null) {
285 groups.push([
286 <Box key="seg-digest" flexDirection="row">
287 {isDesktop ? <Text>{`${GLYPH.digest} `}</Text> : null}
288 {labeled(segs.digest, { bold: true }, {})}
289 </Box>,
290 ])
291 }
292 const buttons: RenderNode[] = []
293 const look = letters ?? (segs.compact.plain ? {} : { variant: 'primary' as const })
294 buttons.push(<Button key="compact" label="Compact" {...hot('c')} {...look} onPress={() => pressCompact($)} />)
295 if (segs.clear !== null) {
296 buttons.push(<Text>{' '}</Text>)
297 buttons.push(<Button key="clear" label="Clear" {...hot('x')} {...(letters ?? { dimColor: true })} onPress={() => pressClear($)} />)
298 }
299 if (segs.progress !== null) {
300 buttons.push(<Text>{' '}</Text>)
301 buttons.push(<Button key="progress" label="Progress" {...hot('p')} {...(letters ?? { dimColor: true })} onPress={() => {}} />)
302 }
303 if (segs.log !== null) {
304 buttons.push(<Text>{' '}</Text>)
305 buttons.push(<Button key="log" label="Log" {...hot('l')} {...(letters ?? { dimColor: true })} onPress={() => {}} />)
306 }
307 // A desktop draws native buttons: in the figures' row they squash it and sit far right, so they get a row of
308 // their own below, left-aligned. The terminal keeps one row: its height is the scarce side there.
309 if (!isDesktop) groups.push(buttons)
310
311 const row = groups.flatMap((g, i) => (i === 0 ? g : [<Text dimColor>{SEP}</Text>, ...g]))
312 // No overflow="hidden" here: it would clip the terminal's cards on the rule row above.
313 const rowBox = (
314 <Box flexDirection="row">
315 {row}
316 </Box>
317 )
318 // A dim rule separates the band from the transcript above it; the desktop frames its panel itself.
319 if (isDesktop) {
320 // A row of air between the figures and the buttons, and around the whole, so the panel is not one dense block.
321 return (
322 <Box flexDirection="column" gap={1} padding={1}>
323 {rowBox}
324 <Box flexDirection="row">{buttons}</Box>
325 </Box>
326 )
327 }
328 const ruleCols = e.props.bodyColumns && e.props.bodyColumns > 0 ? Math.min(e.props.bodyColumns, 400) : 80
329 return (
330 <Box flexDirection="column">
331 <Text dimColor>{RULE.repeat(ruleCols)}</Text>
332 {rowBox}
333 </Box>
334 )
335 })
336}
337hooks/mods/checklist.tsx 114 lines1// Progress pane: /band-progress and the band's Progress button toggle it; it draws the task base or fnd resolved
2// as the generic checklist (title, subtitle, rows, footer) and never writes their state. Picking the task stays
3// the publisher's (`/base-progress <KEY>` or `/fnd-progress <KEY>` pins one); band redraws from the subscription.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, On } from 'claude-code'
6import type { Checklist, ChecklistMark } from '../../types'
7import { CHECKLIST_COMMAND, CHECKLIST_PANE } from './events.ts'
8import { pickProgress, toChecklist } from './lib.ts'
9
10const paneShown = atom({ plugin: 'band', key: 'paneShown' } as const, false)
11const baseProgress = atom({ plugin: 'base', key: 'progress' } as const, null)
12const fndProgress = atom({ plugin: 'fnd', key: 'progress' } as const, null)
13
14const GLYPH: Record<ChecklistMark, string> = { done: '✓', current: '▶', waiting: '◌', todo: '☐' }
15export const NO_CHECKLIST_TEXT =
16 'No task checklist: none of the loaded plugins publishes one (with base: /base-progress <KEY> pins one; with fnd: /fnd-progress <KEY>).'
17
18type $ = EngineInterface
19
20/** base.progress ?? fnd.progress as the checklist, its hints naming the publisher's skill. */
21async function checklist($: $): Promise<Checklist | null> {
22 const p = pickProgress(await read($, baseProgress), await read($, fndProgress))
23 return p === null ? null : toChecklist(p.snapshot, p.publisher)
24}
25
26/** The pane's lines as plain text, for the surfaces that draw no pane. */
27export function checklistText(c: Checklist): string {
28 const head = [c.title, c.subtitle].filter(Boolean).join(' · ')
29 return [head, ...c.rows.map(r => `${GLYPH[r.mark]} ${r.text}`), ...(c.footer ?? [])].join('\n')
30}
31
32/** Only the terminal and the Desktop app draw a mod's panes; elsewhere the checklist goes out as text. */
33async function drawsPanes($: $): Promise<boolean> {
34 return (await $.session.surfaces()).some(s => s === 'terminal' || s === 'desktop')
35}
36
37async function toggle($: $): Promise<string> {
38 if (!(await drawsPanes($))) {
39 const c = await checklist($)
40 return c === null ? NO_CHECKLIST_TEXT : checklistText(c)
41 }
42 if ((await $.ui.panes()).some(p => p.id === CHECKLIST_PANE && p.isShown)) {
43 // The ui.close hook below clears paneShown: this close comes back through it with origin plugin.
44 await $.ui.close({ id: CHECKLIST_PANE })
45 return 'Progress pane closed.'
46 }
47 if ((await checklist($)) === null) return NO_CHECKLIST_TEXT
48 const r = await $.ui.open({ id: CHECKLIST_PANE, title: 'Progress', focus: true, closeOnEscape: true })
49 if (!r.isPlaced) {
50 $.ui.toast(`progress pane not placed: ${r.reason}`)
51 return `Progress pane not placed: ${r.reason}`
52 }
53 await update($, paneShown, () => true)
54 return 'Progress pane opened.'
55}
56
57export function registerChecklist(on: On): void {
58 on('session.start', { cwd: /./ }, async ($, e, next) => {
59 const r = await next(e)
60 try {
61 const shown = (await $.ui.panes()).some(p => p.id === CHECKLIST_PANE && p.isPlaced)
62 await update($, paneShown, () => shown)
63 } catch {}
64 return r
65 })
66
67 on('command.run', { command: CHECKLIST_COMMAND.name }, async $ => ({ text: await toggle($) }))
68
69 // Answers without next, so the band Button's own closure never runs and the pane toggles once.
70 on('ui.press', { plugin: 'band', element: 'progress' }, async ($, e) => {
71 await toggle($)
72 return { element: e.element }
73 })
74
75 on('ui.close', { id: CHECKLIST_PANE }, async ($, e, next) => {
76 const r = await next(e)
77 if (e.origin.kind === 'plugin' || e.origin.kind === 'person') await update($, paneShown, () => false)
78 return r
79 })
80
81 on('ui.render', { component: 'Pane', requestId: CHECKLIST_PANE }, async ($, e) => {
82 const { Box, Text } = $.ui.resolve(e)
83 const c = await checklist($)
84 if (c === null) {
85 return (
86 <Box flexDirection="column" width={e.props.bodyColumns}>
87 <Text dimColor wrap="truncate-end">
88 no task checklist
89 </Text>
90 </Box>
91 )
92 }
93 return (
94 <Box flexDirection="column" width={e.props.bodyColumns}>
95 <Text bold wrap="truncate-end">
96 {[c.title, c.subtitle].filter(Boolean).join(' · ')}
97 </Text>
98 {c.rows.map((row, i) => (
99 <Box key={`row-${i}`}>
100 <Text wrap="truncate-end" dimColor={row.mark === 'done' || row.mark === 'waiting'} bold={row.mark === 'current'}>
101 {`${GLYPH[row.mark]} ${row.text}`}
102 </Text>
103 </Box>
104 ))}
105 {(c.footer ?? []).map(line => (
106 <Text dimColor wrap="truncate-end">
107 {line}
108 </Text>
109 ))}
110 </Box>
111 )
112 })
113}
114hooks/mods/info.ts 53 lines1// band.info: the snapshot that tells another plugin (fnd, which then stops drawing) that band is loaded, which
2// version, and whether its `disabled` option is on. Also registers band's slash commands, again after a /clear.
3import type { EngineInterface, On, PluginOptions } from 'claude-code'
4import { CHECKLIST_COMMAND, DEBUG_COMMAND, LOG_COMMAND } from './events.ts'
5
6type $ = EngineInterface
7
8async function version($: $): Promise<string> {
9 try {
10 const v = (JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: unknown }).version
11 return typeof v === 'string' && v ? v : 'unknown'
12 } catch {
13 return 'unknown'
14 }
15}
16
17async function registerCommands($: $): Promise<void> {
18 await $.command.register(LOG_COMMAND).catch(() => undefined)
19 await $.command.register(CHECKLIST_COMMAND).catch(() => undefined)
20 await $.command.register(DEBUG_COMMAND).catch(() => undefined)
21}
22
23export function registerInfo(on: On, options: PluginOptions): void {
24 /** The session the commands were registered for: a /clear starts a new one with no session.start. */
25 let registeredFor: string | null = null
26
27 on('session.start', { cwd: /^/ }, async ($, e, next) => {
28 // One throwing session.start hook skips every band session.start hook: each $ call fails alone.
29 try {
30 await $.state.set({ plugin: 'band', key: 'info' }, { v: 1, version: await version($), disabled: options.disabled === true })
31 } catch {}
32 try {
33 registeredFor = String(await $.session.id())
34 } catch {}
35 await registerCommands($)
36 return next(e)
37 })
38
39 // band's one matcherless prompt.submit: a module holds at most one.
40 on('prompt.submit', async ($, e, next) => {
41 const r = await next(e)
42 let sid: string | null = null
43 try {
44 sid = String(await $.session.id())
45 } catch {}
46 if (sid !== null && sid !== registeredFor) {
47 await registerCommands($)
48 registeredFor = sid
49 }
50 return r
51 })
52}
53hooks/mods/log.tsx 99 lines1// Event log pane: /band-log and the band's Log button toggle it; it draws band's own lines merged with base's, fnd's,
2// slim's and the team plugins' straight from their state (a plugin not loaded adds nothing), and never writes any of them.
3import { atom, read } from 'claude-code'
4import type { EngineInterface, On } from 'claude-code'
5import type { BandEvent, ForeignEvent, LogLine } from '../../types'
6import { LOG_COMMAND, LOG_PANE, PREFIX_COLS, hhmm, kindCell, logRow, merged, newestFitting, pluginCell } from './events.ts'
7
8const events = atom({ plugin: 'band', key: 'events' } as const, [] as BandEvent[])
9const baseEvents = atom({ plugin: 'base', key: 'events' } as const, [] as ForeignEvent[])
10const fndEvents = atom({ plugin: 'fnd', key: 'events' } as const, [] as ForeignEvent[])
11const slimEvents = atom({ plugin: 'slim', key: 'events' } as const, [] as ForeignEvent[])
12const feEvents = atom({ plugin: 'fe', key: 'events' } as const, [] as ForeignEvent[])
13const qaEvents = atom({ plugin: 'qa', key: 'events' } as const, [] as ForeignEvent[])
14const beEvents = atom({ plugin: 'be', key: 'events' } as const, [] as ForeignEvent[])
15const pmEvents = atom({ plugin: 'pm', key: 'events' } as const, [] as ForeignEvent[])
16
17type $ = EngineInterface
18
19/** Only the terminal and the Desktop app draw a mod's panes; elsewhere the log goes out as text. */
20async function drawsPanes($: $): Promise<boolean> {
21 return (await $.session.surfaces()).some(s => s === 'terminal' || s === 'desktop')
22}
23
24async function allEvents($: $): Promise<LogLine[]> {
25 return merged(await read($, events), await read($, baseEvents), await read($, fndEvents), await read($, slimEvents), {
26 fe: await read($, feEvents),
27 qa: await read($, qaEvents),
28 be: await read($, beEvents),
29 pm: await read($, pmEvents),
30 })
31}
32
33async function logText($: $): Promise<string> {
34 const list = await allEvents($)
35 if (list.length === 0) return 'no events yet'
36 return list.map(logRow).join('\n')
37}
38
39async function togglePane($: $): Promise<string> {
40 if (!(await drawsPanes($))) return await logText($)
41 if ((await $.ui.panes()).some(p => p.id === LOG_PANE && p.isShown)) {
42 await $.ui.close({ id: LOG_PANE })
43 return 'Log pane closed.'
44 }
45 const r = await $.ui.open({ id: LOG_PANE, title: 'Log', focus: true, closeOnEscape: true })
46 if (!r.isPlaced) {
47 $.ui.toast(`log pane not placed: ${r.reason}`)
48 return `Log pane not placed: ${r.reason}`
49 }
50 return 'Log pane opened.'
51}
52
53export function registerLog(on: On): void {
54 on('command.run', { command: LOG_COMMAND.name }, async $ => ({ text: await togglePane($) }))
55
56 // Answers without next, so the band Button's own closure never runs and the pane toggles once.
57 on('ui.press', { plugin: 'band', element: 'log' }, async ($, e) => {
58 await togglePane($)
59 return { element: e.element }
60 })
61
62 on('ui.render', { component: 'Pane', requestId: LOG_PANE }, async ($, e) => {
63 const { Box, Text } = $.ui.resolve(e)
64 const list = await allEvents($)
65 if (list.length === 0) {
66 return (
67 <Box flexDirection="column" width={e.props.bodyColumns}>
68 <Text dimColor wrap="truncate-end">
69 no events yet
70 </Text>
71 </Box>
72 )
73 }
74 // The engine's window starts at the top and never follows the end: keep the newest rows in view,
75 // counting the rows a wrapped text takes.
76 const rows = e.props.scroll?.bodyRows ?? 0
77 const textCols = Math.max(1, e.props.bodyColumns - PREFIX_COLS)
78 const shown = newestFitting(list, rows, textCols)
79 const cut = shown.length < list.length
80 return (
81 <Box flexDirection="column" width={e.props.bodyColumns}>
82 {cut ? <Text dimColor wrap="truncate-end">{`… ${list.length - shown.length} earlier`}</Text> : null}
83 {shown.map((ev, i) => (
84 <Box key={`ev-${i}`} flexDirection="row">
85 <Box width={PREFIX_COLS} flexShrink={0}>
86 <Text dimColor>{`${hhmm(ev.atMs)} `}</Text>
87 <Text dimColor>{`${pluginCell(ev.plugin)} `}</Text>
88 <Text dimColor>{`${kindCell(ev.kind)} `}</Text>
89 </Box>
90 <Box width={textCols}>
91 <Text wrap="wrap">{ev.text}</Text>
92 </Box>
93 </Box>
94 ))}
95 </Box>
96 )
97 })
98}
99hooks/mods/marker.ts 43 lines1// Session marker: tells fnd's classic UserPromptSubmit hook (plugins/fnd/hooks/mod-session.cjs) that a band is
2// drawn, so its context monitor goes silent — the band shows ctx and model. The file name is that hook's, so it
3// keeps fnd's prefix. Rewritten on every prompt, because the classic side trusts only a fresh mtime: a resumed
4// session whose module no longer loads must not inherit a stale file. With `disabled` on nothing is written, so
5// the monitor speaks again. Never deleted ($.fs cannot remove); old empty markers stay in tmpdir.
6import type { EngineInterface, On, PluginOptions } from 'claude-code'
7
8type $ = EngineInterface
9
10const MARK_MS = 500 // a stalled fs.write must not hold the prompt
11
12export function registerMarker(on: On, options: PluginOptions): void {
13 let broken: string | null = null // a session whose tmpdir failed once is not retried on every prompt
14 // Matches every prompt: a module holds one matcherless prompt.submit hook, and info.ts has it.
15 on('prompt.submit', { text: /^/ }, async ($, e, next) => {
16 if (options.disabled === true) return next(e)
17 const sid = await sessionId($).catch(() => '')
18 if (sid && sid !== broken) {
19 const ok = await Promise.race([
20 mark($, sid).then(() => true),
21 $.clock.sleep(MARK_MS, { signal: next.signal }).then(() => false),
22 ]).catch(() => false)
23 if (!ok) broken = sid
24 }
25 return next(e)
26 })
27}
28
29async function sessionId($: $): Promise<string> {
30 return String(await $.session.id()).replace(/[^A-Za-z0-9_.-]/g, '')
31}
32
33/** Writes the empty `<tmpdir>/fnd-mod-session-<sid>`. */
34async function mark($: $, sid: string): Promise<void> {
35 await $.fs.write(`${await tmpdir($)}/fnd-mod-session-${sid}`, '')
36}
37
38/** Node's os.tmpdir() on POSIX (TMPDIR, TMP, TEMP, else /tmp) without trailing slashes: '/' gives '', so the joined path matches path.join. */
39async function tmpdir($: $): Promise<string> {
40 const dir = (await $.env.get('TMPDIR')) || (await $.env.get('TMP')) || (await $.env.get('TEMP')) || '/tmp'
41 return dir.replace(/\/+$/, '')
42}
43hooks/mods/usage.ts 345 lines1// Status band writers: usage, model, cache and tick atoms plus the tick timer, band's own event lines
2// (session, model, compact, rate) with their file on disk, and /band-debug. Nothing here draws; the band
3// reads these atoms.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, On, PluginOptions } from 'claude-code'
6import type { BandEvent, BandEventKind, BandUsage } from '../../types'
7import { DEBUG_COMMAND, capLines, fileLine, fmtK, logDir, pushEvent, seedLines } from './events.ts'
8import { CACHE_INIT, HOUR_MS, USAGE_INIT, alarmRate, compactedUsage, keepCtx, oneHourCacheTokens, pickProgress, rateCard, seedsTtl, toUsage, ttlMsOf } from './lib.ts'
9import { lastRender, turn } from './band.tsx'
10
11const TICK_MS = 30_000
12const ALARM_TOAST_MS = 8000
13const STORE_TTL = 'cacheTtlMs'
14/** Below this much of session.end's shared bound, `session clear` stays off the disk rather than risk an abort. */
15const END_FILE_MIN_MS = 500
16
17const usage = atom({ plugin: 'band', key: 'usage' } as const, USAGE_INIT)
18const model = atom({ plugin: 'band', key: 'model' } as const, null)
19const cache = atom({ plugin: 'band', key: 'cache' } as const, CACHE_INIT)
20const tick = atom({ plugin: 'band', key: 'tick' } as const, 0)
21const rateAlarmed = atom({ plugin: 'band', key: 'rateAlarmed' } as const, false)
22const events = atom({ plugin: 'band', key: 'events' } as const, [] as BandEvent[])
23const info = atom({ plugin: 'band', key: 'info' } as const, null)
24const baseProgress = atom({ plugin: 'base', key: 'progress' } as const, null)
25const fndProgress = atom({ plugin: 'fnd', key: 'progress' } as const, null)
26
27type $ = EngineInterface
28
29/** This session's lines of band.jsonl as last written; `path` tells a new session (or a reload) apart. */
30let file: { path: string; lines: string[] } | null = null
31let fileVersion: string | null = null
32let toastedFor: string | null = null
33/** Whole-file writes, one at a time: two hooks writing at once would each drop the other's line. */
34let fileQueue: Promise<void> = Promise.resolve()
35
36/**
37 * Appends one line to `<dir>/<session-id>/band.jsonl`, rewriting the whole file (the engine writes no
38 * append). The first write of a module or of a new session id seeds from the file, so a reload keeps the
39 * record. A session's first line is a start line, as no session.start follows a /clear or a resume; a
40 * start line comes once per session, so a fresh process resuming an id whose file has one adds none. `sid`
41 * pins the session (session.end names the ending one). No HOME and no DOMAINE_LOG_DIR → no file. A
42 * failure toasts once per session unless `quiet`, and goes no further.
43 */
44async function writeFileLine($: $, atMs: number, kind: string, text: string, sid?: string, quiet = false): Promise<void> {
45 let id = sid ?? ''
46 try {
47 id = sid ?? String(await $.session.id())
48 const dir = logDir(await $.env.get('HOME'), await $.env.get('DOMAINE_LOG_DIR'), id)
49 if (dir === null) return
50 const path = `${dir}/band.jsonl`
51 fileVersion ??= (await read($, info).catch(() => null))?.version ?? null
52 const version = fileVersion ?? 'unknown'
53 let prior = file?.path === path ? file.lines : seedLines((await $.fs.exists(path)) ? String(await $.fs.read(path)) : '', id)
54 if (kind === 'start' && prior.some(l => JSON.parse(l).kind === 'start')) {
55 file = { path, lines: prior }
56 return
57 }
58 if (kind !== 'start' && !prior.length) prior = [fileLine(atMs, version, id, 'start', `band ${version}`)]
59 const lines = capLines([...prior, fileLine(atMs, version, id, kind, text)])
60 file = { path, lines }
61 await $.fs.write(path, `${lines.join('\n')}\n`)
62 } catch (err) {
63 if (quiet || toastedFor === id) return
64 toastedFor = id
65 try {
66 $.ui.toast(`band: event log not written: ${err instanceof Error ? err.message : String(err)}`)
67 } catch {}
68 }
69}
70
71/** Queues one file line behind the writes in flight, so no whole-file write drops another's line. */
72function persist($: $, atMs: number, kind: string, text: string, sid?: string, quiet = false): Promise<void> {
73 const job = fileQueue.then(() => writeFileLine($, atMs, kind, text, sid, quiet))
74 fileQueue = job.catch(() => undefined)
75 return job
76}
77
78/**
79 * Appends one event-log line to band.events and band.jsonl; `onDisk` replaces kind and text in the file only.
80 * BAND_EVENT_LOG=0 skips both. It never throws, because a throwing session.start hook would skip every
81 * band session.start hook.
82 */
83async function logEvent($: $, kind: BandEventKind, text: string, onDisk?: { kind: string; text: string }): Promise<void> {
84 try {
85 if ((await $.env.get('BAND_EVENT_LOG')) === '0') return
86 const atMs = await $.clock.now()
87 await update($, events, l => pushEvent(l, { atMs, kind, text }))
88 await persist($, atMs, onDisk?.kind ?? kind, onDisk?.text ?? text)
89 } catch {}
90}
91
92/** BAND_COST=1 (true/yes/on) shows the session's cost; off, the figure is dropped before it reaches the atom. */
93let costShown = false
94const ON = new Set(['1', 'true', 'yes', 'on'])
95
96function withCost(u: BandUsage): BandUsage {
97 return costShown ? u : { ...u, costUsd: null }
98}
99
100/** Writes the model atom and logs the change once; the same id again is a no-op. */
101async function adoptModel($: $, m: string): Promise<void> {
102 if ((await read($, model)) === m) return
103 await update($, model, () => m)
104 await logEvent($, 'model', m)
105}
106
107const SAME_COMPACTION_MS = 30_000
108let lastCompactMs = 0
109
110/**
111 * Cold cache, the context from the engine's count (absent = no reading), one log line. The
112 * session.compact chain and the classic PostCompact both report one compaction, in either order: a
113 * report within 30 s of the last is the same compaction, and then only a token count refines the context.
114 */
115async function applyCompaction($: $, trigger: string, tokensAfter?: number, tokensBefore?: number): Promise<void> {
116 const now = await $.clock.now()
117 const same = now - lastCompactMs < SAME_COMPACTION_MS
118 lastCompactMs = now
119 await update($, cache, c => ({ ...c, isCold: true }))
120 if (same && tokensAfter === undefined) return
121 await update($, usage, u => compactedUsage(u, tokensAfter))
122 if (same) return
123 const sizes = typeof tokensBefore === 'number' && typeof tokensAfter === 'number' ? ` ${fmtK(tokensBefore)} → ${fmtK(tokensAfter)}` : ''
124 await logEvent($, 'compact', `${trigger}${sizes}`)
125}
126
127async function refresh($: $): Promise<void> {
128 const now = await $.clock.now()
129 await update($, tick, () => now)
130 const u = withCost(toUsage(...(await $.session.usage().then(r => [r.context, r.rateLimits, r.cost] as const))))
131 await update($, usage, prev => keepCtx(prev, u))
132 await adoptSubscriptionTtl($, u)
133}
134
135/**
136 * An API key or a cloud provider bills per request and gets the 5 min cache; without them the session
137 * runs on a claude.ai account, whose cache lives 1 h. Only the presence of the variables is read.
138 */
139async function defaultTtl($: $): Promise<number> {
140 const billed = [
141 await $.env.get('ANTHROPIC_API_KEY'),
142 await $.env.get('CLAUDE_CODE_USE_BEDROCK'),
143 await $.env.get('CLAUDE_CODE_USE_VERTEX'),
144 await $.env.get('CLAUDE_CODE_USE_FOUNDRY'),
145 ].some(v => v !== undefined)
146 return billed ? CACHE_INIT.ttlMs : HOUR_MS
147}
148
149/**
150 * Rate-limit windows arrive only on a claude.ai subscription, whose prompt cache lives 1 h (5 min
151 * under an API key or in overage). Live evidence beats the default and a remembered value alike.
152 */
153async function adoptSubscriptionTtl($: $, u: BandUsage): Promise<void> {
154 if (u.rates.length === 0) return
155 await update($, cache, c =>
156 c.ttlSource === 'default' || c.ttlSource === 'store' ? { ...c, ttlMs: HOUR_MS, ttlSource: 'subscription' } : c,
157 )
158}
159
160/**
161 * Adopts a TTL the session reported. Only 1 h is remembered for the next sessions: a 5 min report is
162 * the host's fallback when it has seen no 1 h cache write yet (every session start on 2.1.289), and
163 * remembered it would outlive the session that made it.
164 */
165async function learnTtl($: $, ttlMs: number, ttlSource: 'model-switch' | 'agent'): Promise<void> {
166 await update($, cache, c => ({ ...c, ttlMs, ttlSource }))
167 if (ttlMs === HOUR_MS) await $.store.set(STORE_TTL, ttlMs)
168}
169
170/** Another plugin writes the snapshot: only a string or null id and branch are shown. */
171function progressLine(p: unknown): { workId: string | null; branch: string | null } | null {
172 if (p === null || typeof p !== 'object') return null
173 const s = p as { workId?: unknown; branch?: unknown }
174 const str = (v: unknown) => (typeof v === 'string' ? v : null)
175 return { workId: str(s.workId), branch: str(s.branch) }
176}
177
178export function registerUsage(on: On, options: PluginOptions): void {
179 const forcedTtl = ttlMsOf(options.cacheTtl)
180
181 // A /config change reloads the module and session.start runs again in the same session: the tick, set by
182 // the first one, tells a reload apart, so it logs no second start line and keeps a learned TTL.
183 on('session.start', async ($, e, next) => {
184 $.clock.every(TICK_MS, () => {
185 void refresh($).catch(() => undefined)
186 })
187 // One throwing session.start hook skips every band session.start hook: each $ call fails alone.
188 const reloaded = (await read($, tick).catch(() => 0)) > 0
189 if (seedsTtl(reloaded, forcedTtl, (await read($, cache).catch(() => CACHE_INIT)).ttlSource)) {
190 let learned: number | null = null
191 if (forcedTtl === null) {
192 try {
193 const stored = await $.store.get(STORE_TTL)
194 learned = typeof stored === 'number' && stored > 0 ? stored : null
195 } catch {}
196 }
197 const ttlMs = forcedTtl ?? learned ?? (await defaultTtl($).catch(() => CACHE_INIT.ttlMs))
198 const ttlSource = forcedTtl !== null ? 'option' : learned !== null ? 'store' : 'default'
199 await update($, cache, c => ({ ...c, ttlMs, ttlSource })).catch(() => undefined)
200 }
201 costShown = ON.has((await $.env.get('BAND_COST').catch(() => undefined))?.trim().toLowerCase() ?? '')
202 try {
203 const m = await $.session.model()
204 await update($, model, () => m)
205 } catch {}
206 await refresh($).catch(() => undefined)
207 // The start line names the drawer: fnd's own line reads `start`, so /band-log tells the two apart.
208 if (!reloaded) {
209 const v = (await read($, info).catch(() => null))?.version ?? '?'
210 await logEvent($, 'session', `start · band ${v}`, { kind: 'start', text: `band ${v}` })
211 }
212 return next(e)
213 })
214
215 on('command.run', { command: DEBUG_COMMAND.name }, async ($) => {
216 const raw = await $.session.usage().catch(err => ({ error: String(err) }))
217 const root = await $.session.root().catch(err => `error: ${String(err)}`)
218 const c = await read($, cache)
219 const u = await read($, usage)
220 const picked = pickProgress(await read($, baseProgress), await read($, fndProgress))
221 const lines = [
222 'band debug',
223 `info: ${JSON.stringify(await read($, info))}`,
224 `usage(): ${JSON.stringify(raw)}`,
225 `cache: ${JSON.stringify(c)}`,
226 `usage atom: ${JSON.stringify(u)}`,
227 `progress: ${JSON.stringify(progressLine(picked?.snapshot ?? null))}`,
228 `publisher: ${picked?.publisher ?? 'none'}`,
229 `root: ${root}`,
230 `render: ${JSON.stringify(lastRender)}`,
231 `tick: ${await read($, tick)}`,
232 `now: ${await $.clock.now()}`,
233 ]
234 return { text: lines.join('\n') }
235 })
236
237 on('session.measure', async ($, e, next) => {
238 const r = await next(e)
239 const u = withCost(toUsage(e.context, e.rateLimits, e.cost))
240 await update($, usage, prev => keepCtx(prev, u))
241 await adoptSubscriptionTtl($, u)
242 // No session.start follows a /clear and the seed may read null: the first measure fills the gap. A
243 // switch whose PostModelSwitch never reached the mod is caught here too.
244 try {
245 const m = await $.session.model()
246 if (m) await adoptModel($, m)
247 } catch {}
248 const hot = alarmRate(u.rates)
249 const isAlarmed = await read($, rateAlarmed)
250 if (hot && !isAlarmed) {
251 await update($, rateAlarmed, () => true)
252 const card = rateCard(hot, await $.clock.now())
253 $.ui.toast(card, { timeoutMs: ALARM_TOAST_MS })
254 await logEvent($, 'rate', card)
255 } else if (!hot && isAlarmed) {
256 await update($, rateAlarmed, () => false)
257 }
258 return r
259 })
260
261 on('turn.complete', async ($, e, next) => {
262 if (e.agentId === undefined) turn.running = false
263 if (e.agentId === undefined && e.usage) {
264 const now = await $.clock.now()
265 await update($, cache, c => ({ ...c, anchorMs: now, isCold: false }))
266 await update($, tick, () => now)
267 }
268 return next(e)
269 })
270
271 on('session.compact', async ($, e, next) => {
272 const r = await next(e)
273 if (r.skip === undefined && r.messages && e.trigger !== 'precompute' && e.agentId === undefined) {
274 await applyCompaction($, e.trigger, r.tokensAfter, r.tokensBefore)
275 }
276 return r
277 })
278
279 // The engine's own report of a main-thread compaction. It reaches the mod when the session.compact
280 // chain does not (a Compact press on 2.1.289 left the band warm at the old ctx).
281 on('classic.PostCompact', async ($, e, next) => {
282 if (e.agent_id === undefined) await applyCompaction($, e.trigger)
283 return next(e)
284 })
285
286 // One short wall-clock bound covers the whole session.end chain, $ waits included: the atoms and the
287 // pane line go first, the disk write only after the chain beneath has run and while time is left.
288 on('session.end', async ($, e, next) => {
289 if (e.reason === 'clear' || e.reason === 'resume') {
290 await update($, cache, c => ({ ...c, anchorMs: null, isCold: false }))
291 await update($, usage, u => ({ ...u, ctxPct: null, ctxTokens: null }))
292 await update($, rateAlarmed, () => false)
293 }
294 // No session.start follows a /clear: this line marks where the conversation restarted.
295 let clearedAt: number | null = null
296 if (e.reason === 'clear') {
297 try {
298 if ((await $.env.get('BAND_EVENT_LOG')) !== '0') {
299 const atMs = await $.clock.now()
300 await update($, events, l => pushEvent(l, { atMs, kind: 'session', text: 'clear' }))
301 clearedAt = atMs
302 }
303 } catch {}
304 }
305 const r = await next(e)
306 if (clearedAt !== null && next.budget.remainingMs >= END_FILE_MIN_MS) {
307 await persist($, clearedAt, 'session', 'clear', e.sessionId, true).catch(() => undefined)
308 }
309 return r
310 })
311
312 on('classic.PostModelSwitch', async ($, e, next) => {
313 // The event's own field: $.session.model() may still answer the model before the switch here.
314 await adoptModel($, e.to_model)
315 // On a subscription (rate windows seen) the cache lives 1 h; a 5 min report there is the host's
316 // fallback, not a measurement. Overage does drop it to 5 min, and the band hides the segment then.
317 const ttlMs = ttlMsOf(e.cache_ttl)
318 const subscribed = (await read($, usage)).rates.length > 0
319 if (forcedTtl === null && ttlMs !== null && !(subscribed && ttlMs !== HOUR_MS)) await learnTtl($, ttlMs, 'model-switch')
320 // Caches are per model: a real switch forfeits the warm one. On resume the SessionStart seed decides.
321 if (e.source !== 'resume' && (e.from_model !== e.to_model || !e.prompt_cache_warm)) {
322 await update($, cache, c => ({ ...c, isCold: true }))
323 }
324 return next(e)
325 })
326
327 on('classic.SessionStart', { source: /^(resume|fork)$/ }, async ($, e, next) => {
328 await logEvent($, 'session', e.source)
329 const secs = e.seconds_since_last_response
330 if (typeof secs === 'number') {
331 const now = await $.clock.now()
332 const isCold = e.prompt_cache_likely_expired === true
333 await update($, cache, c => ({ ...c, anchorMs: now - secs * 1000, isCold }))
334 await update($, tick, () => now)
335 }
336 return next(e)
337 })
338
339 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
340 const r = await next(e)
341 if (forcedTtl === null && oneHourCacheTokens(r.result) > 0) await learnTtl($, HOUR_MS, 'agent')
342 return r
343 })
344}
345hooks/mods/lib.ts 452 lines1// Pure band helpers: the ctx/rate/cache colour ladders, rate labels, remaining-time text, the one-row
2// width model with its drop order, and fnd's task snapshot read as the generic checklist. No `$` here:
3// atoms and engine calls stay in the file that hooks the event.
4import type { BandCache, BandRate, BandUsage, Checklist, ChecklistMark, ChecklistRow } from '../../types'
5
6export type Level = 'plain' | 'warning' | 'crit'
7
8/** The alarm look; needs no theme key beyond `warning` until another is proven live on every theme. */
9export const CRIT = { color: 'warning', bold: true, inverse: true } as const
10
11/** Text props per level. */
12export const LEVEL_PROPS: Record<Level, { color?: string; bold?: boolean; inverse?: boolean }> = {
13 plain: {},
14 warning: { color: 'warning' },
15 crit: CRIT,
16}
17/** The ctx value alone is green while fine, as the classic notice's 🟢 was; the rest of the band stays plain there. */
18export const CTX_PROPS: typeof LEVEL_PROPS = { ...LEVEL_PROPS, plain: { color: 'success' } }
19
20/** ctx % and every rate window: ≤ 30 plain, > 30 warning, ≥ 80 crit, judged on the rounded figure the band shows. */
21export function pctLevel(pct: number): Level {
22 const shown = Math.round(pct)
23 if (shown >= 80) return 'crit'
24 if (shown > 30) return 'warning'
25 return 'plain'
26}
27
28const MIN = 60_000
29
30/**
31 * Minutes left of the cache: warning below 10 min, crit below 2 min for a 1 h TTL. A shorter TTL
32 * scales both: warning below 40 % of the TTL (when that is under 10 min), crit at a fifth of that.
33 */
34export function cacheLevel(remainingMs: number, ttlMs: number): Level {
35 const warnMs = Math.min(10 * MIN, 0.4 * ttlMs)
36 if (remainingMs < warnMs / 5) return 'crit'
37 if (remainingMs < warnMs) return 'warning'
38 return 'plain'
39}
40
41/** Remaining cache time at `nowMs`, or null before the first main-thread response. */
42export function cacheRemaining(cache: BandCache, nowMs: number): number | null {
43 return cache.anchorMs === null ? null : cache.anchorMs + cache.ttlMs - nowMs
44}
45
46/** `42m`, `<1m`; null → `—`. Callers render `cold` for remaining ≤ 0 themselves via cacheView. */
47export function fmtRemaining(ms: number | null): string {
48 if (ms === null) return '—'
49 if (ms < MIN) return '<1m'
50 return `${Math.floor(ms / MIN)}m`
51}
52
53export type CacheView = { text: string; level: Level }
54
55/** The cache segment: `cache ●` mid-turn, `cache —` unmeasured, `cache cold`, else the countdown. */
56export function cacheView(cache: BandCache, nowMs: number, isWorking: boolean): CacheView {
57 if (isWorking) return { text: 'cache ●', level: 'plain' }
58 const left = cacheRemaining(cache, nowMs)
59 if (left === null) return { text: 'cache —', level: 'plain' }
60 if (cache.isCold || left <= 0) return { text: 'cache cold', level: 'plain' }
61 return { text: `cache ${fmtRemaining(left)}`, level: cacheLevel(left, cache.ttlMs) }
62}
63
64export function ctxText(pct: number | null): string {
65 return pct === null ? 'ctx —' : `ctx ${Math.round(pct)}%`
66}
67
68const KNOWN_KINDS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: '$' }
69const LABEL_MAX = 10
70
71/** `five_hour` → `5h`; an unknown kind keeps its raw name, shortened (`seven_day_fable` → `7d·fable`). */
72export function rateLabel(kind: string): string {
73 const known = KNOWN_KINDS[kind]
74 if (known !== undefined) return known
75 const short = kind
76 .replace(/^five_hour/, '5h')
77 .replace(/^seven_day/, '7d')
78 .replace(/_+/g, '·')
79 .replace(/^·+|·+$/g, '')
80 return [...(short || kind)].slice(0, LABEL_MAX).join('')
81}
82
83/** Rounded for display; above 100 (a spend limit) reads `>100%`. */
84export function fmtPct(pct: number): string {
85 return pct > 100 ? '>100%' : `${Math.round(pct)}%`
86}
87
88export type RawRate = { kind: string; percentUsed: number; resetsAt?: string }
89
90/** Every window the API reports, in its order. */
91export function toRates(list: readonly RawRate[] | undefined): BandRate[] {
92 return (list ?? []).map(r => ({
93 kind: r.kind,
94 label: rateLabel(r.kind),
95 pct: r.percentUsed,
96 resetsAt: r.resetsAt ?? null,
97 }))
98}
99
100export function rateText(r: BandRate): string {
101 return `${r.label} ${fmtPct(r.pct)}`
102}
103
104export type BandButton = { key: string; label: string; hotkey: string; plain: boolean }
105
106/** The band's segments in row order; null/empty = not drawn. */
107export type BandSegs = {
108 /** null while a rate window is at or past 100 %: in overage the TTL is unknown, so nothing is shown. */
109 cache: string | null
110 model: string | null
111 ctx: string
112 rates: BandRate[]
113 /** `cost $1.23`; null without a ledger or at zero. */
114 cost: string | null
115 digest: string | null
116 compact: BandButton
117 clear: BandButton | null
118 progress: BandButton | null
119 log: BandButton | null
120}
121
122export const SEP = ' │ '
123/** The dim rule drawn above the row, one cell repeated across the band. */
124export const RULE = '─'
125const RATE_GAP = ' · '
126const BUTTON_GAP = ' '
127
128/** Width sample: the focused form `c: Compact` (letters show only while the band holds the keyboard) or `[ Compact ]`; they differ by one cell. */
129export function buttonText(b: BandButton): string {
130 return b.plain ? `${b.hotkey}: ${b.label}` : `[ ${b.label} ]`
131}
132
133/** The row as the terminal draws it: groups joined by ` │ `. */
134export function rowText(s: BandSegs): string {
135 const groups: string[] = s.cache === null ? [] : [s.cache]
136 if (s.model !== null) groups.push(`${s.model}${MODEL_MARK}`)
137 groups.push(s.ctx)
138 if (s.rates.length) groups.push(s.rates.map(rateText).join(RATE_GAP))
139 if (s.cost !== null) groups.push(s.cost)
140 if (s.digest !== null) groups.push(s.digest)
141 const buttons = [s.compact, s.clear, s.progress, s.log].filter((b): b is BandButton => b !== null)
142 groups.push(buttons.map(buttonText).join(BUTTON_GAP))
143 return groups.join(SEP)
144}
145
146/** Width in cells, one per code point. */
147export function cells(text: string): number {
148 return [...text].length
149}
150
151/**
152 * Drops segments until the row fits `bodyColumns`: the Log button (/band-log stays), the Clear button (/clear
153 * stays), the digest, the cost, then rate windows beyond the fullest (least full first), then the last rate,
154 * the model, the Progress button.
155 * Cache, ctx and Compact are never dropped. 0 or absent columns = a surface that did not measure: kept whole.
156 */
157export function layout(segs: BandSegs, bodyColumns: number | undefined): BandSegs {
158 if (!bodyColumns || bodyColumns <= 0) return segs
159 let s: BandSegs = segs
160 const fits = () => cells(rowText(s)) <= bodyColumns
161 if (fits()) return s
162 if (s.log !== null) s = { ...s, log: null }
163 if (!fits() && s.clear !== null) s = { ...s, clear: null }
164 if (!fits() && s.digest !== null) s = { ...s, digest: null }
165 if (!fits() && s.cost !== null) s = { ...s, cost: null }
166 while (!fits() && s.rates.length > 1) {
167 const fullest = s.rates.reduce((a, b) => (b.pct > a.pct ? b : a))
168 const victim = s.rates.reduce((a, b) => (b !== fullest && (a === fullest || b.pct <= a.pct) ? b : a))
169 s = { ...s, rates: s.rates.filter(r => r !== victim) }
170 }
171 if (!fits() && s.rates.length) s = { ...s, rates: [] }
172 if (!fits() && s.model !== null) s = { ...s, model: null }
173 if (!fits() && s.progress !== null) s = { ...s, progress: null }
174 return s
175}
176
177const HOUR = 60 * MIN
178export const DEFAULT_TTL_MS = 5 * MIN
179
180export const USAGE_INIT: BandUsage = { ctxPct: null, ctxTokens: null, window: 0, rates: [], costUsd: null }
181export const CACHE_INIT: BandCache = { anchorMs: null, ttlMs: DEFAULT_TTL_MS, ttlSource: 'default', isCold: false }
182
183/**
184 * Whether session.start seeds the TTL again. A reload (a /config change) keeps a learned one; a forced TTL is
185 * applied, and leaving a forced one seeds from the store or the default again.
186 */
187export function seedsTtl(reloaded: boolean, forcedTtl: number | null, source: BandCache['ttlSource']): boolean {
188 return !reloaded || forcedTtl !== null || source === 'option'
189}
190
191/** `5m` / `1h` (the userConfig picker and the API's `cache_ttl`) in ms; anything else (`auto`) → null. */
192export function ttlMsOf(v: unknown): number | null {
193 if (v === '5m') return 5 * MIN
194 if (v === '1h') return HOUR
195 return null
196}
197
198export type RawContext = { tokens?: number; window: number; percent?: number }
199
200export type RawCost = { usd: number }
201
202/** `$.session.usage()` / `session.measure` figures as the usage atom holds them. */
203export function toUsage(context: RawContext, rateLimits: readonly RawRate[] | undefined, cost?: RawCost): BandUsage {
204 return {
205 ctxPct: context.percent ?? null,
206 ctxTokens: context.tokens ?? null,
207 window: context.window,
208 rates: toRates(rateLimits),
209 costUsd: typeof cost?.usd === 'number' ? cost.usd : null,
210 }
211}
212
213/** `$0.49`, `$139.14`: the host's /cost total, cents kept so a small session still moves. */
214export function fmtUsd(usd: number): string {
215 return `$${usd.toFixed(2)}`
216}
217
218/** `cost $139.14`; null without a ledger and while the session has cost nothing. */
219export function costText(usd: number | null): string | null {
220 return usd === null || usd <= 0 ? null : `cost ${fmtUsd(usd)}`
221}
222
223/** The cost hover card. */
224export function costCard(usd: number): string {
225 return `session cost: ${fmtUsd(usd)} at API prices, as /cost counts it (a subscription is not billed per request)`
226}
227
228/** `1,234,567`. */
229export function fmtInt(n: number): string {
230 return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
231}
232
233/** `in 42m`, `in 2h 05m`, `in 3d 4h`; null when absent or unparsable. */
234export function fmtResetIn(resetsAt: string | null, nowMs: number): string | null {
235 if (!resetsAt) return null
236 const at = Date.parse(resetsAt)
237 if (Number.isNaN(at)) return null
238 const mins = Math.max(0, Math.round((at - nowMs) / MIN))
239 if (mins < 60) return `in ${mins}m`
240 const h = Math.floor(mins / 60)
241 if (h < 24) return `in ${h}h ${String(mins % 60).padStart(2, '0')}m`
242 return `in ${Math.floor(h / 24)}d ${h % 24}h`
243}
244
245/** `5h window: 61% used, resets in 2h 05m`: the rate hover card and the ≥ 90 % toast. */
246export function rateCard(r: BandRate, nowMs: number): string {
247 const reset = fmtResetIn(r.resetsAt, nowMs)
248 return `${r.label} window: ${fmtPct(r.pct)} used${reset ? `, resets ${reset}` : ''}`
249}
250
251export const RATE_ALARM_PCT = 90
252
253/** The first window whose shown percentage is at or past the alarm line, or null. */
254export function alarmRate(rates: readonly BandRate[]): BandRate | null {
255 return rates.find(r => Math.round(r.pct) >= RATE_ALARM_PCT) ?? null
256}
257
258/** `1 h`, `5 min`. */
259export function ttlText(ms: number): string {
260 return ms % HOUR === 0 ? `${ms / HOUR} h` : `${Math.round(ms / MIN)} min`
261}
262
263export function cacheCard(cache: BandCache, nowMs: number): string {
264 const left = cacheRemaining(cache, nowMs)
265 if (left === null) return 'prompt cache: no response yet'
266 if (cache.isCold || left <= 0) return 'prompt cache: cold, the next request writes it again'
267 const mins = left < MIN ? '<1' : `~${Math.floor(left / MIN)}`
268 return `prompt cache: ${mins} min left (estimate: last response + ${ttlText(cache.ttlMs)} TTL)`
269}
270
271/**
272 * The context right after a compaction: the engine's own count of the kept conversation over the
273 * window already known. It holds until a response reports a measured reading.
274 */
275export function compactedUsage(u: BandUsage, tokensAfter: number | undefined): BandUsage {
276 const known = typeof tokensAfter === 'number' && tokensAfter >= 0
277 return { ...u, ctxTokens: known ? tokensAfter : null, ctxPct: known && u.window > 0 ? (tokensAfter / u.window) * 100 : null }
278}
279
280/** A measurement without a context reading (window only, as right after a compaction) keeps the last one. */
281export function keepCtx(prev: BandUsage, next: BandUsage): BandUsage {
282 return next.ctxPct === null ? { ...next, ctxPct: prev.ctxPct, ctxTokens: prev.ctxTokens } : next
283}
284
285export function ctxCard(u: BandUsage): string {
286 if (u.ctxPct === null) return 'context: no reading yet (fresh session or just compacted)'
287 const used = u.ctxTokens === null ? '' : `, ${fmtInt(u.ctxTokens)} used`
288 return `context: ${Math.round(u.ctxPct)}% of ${fmtInt(u.window)} tokens${used}`
289}
290
291/** Single code points only: a VS16/ZWJ sequence has no settled cell width. */
292export const GLYPH = { cache: '⏱', ctx: '\u{1F9E0}', rates: '⏳', model: '\u{1F916}', digest: '\u{1F4CB}', cost: '\u{1F4B0}' } as const
293
294/** The desktop label: the leading word of `cache 42m` / `ctx 47%` / `cost $1.23` becomes its glyph. */
295export function glyphText(text: string): string {
296 return text
297 .replace(/^cache /, `${GLYPH.cache} `)
298 .replace(/^ctx /, `${GLYPH.ctx} `)
299 .replace(/^cost /, `${GLYPH.cost} `)
300}
301
302export const COMPACT_LOUD_PCT = 80
303
304/** Always drawn first and always pressable, so the buttons never shift; `plain` = the normal look (never dim), the primary button from 80 % between turns. */
305export function compactButton(ctxPct: number | null, isWorking: boolean): BandButton {
306 const loud = ctxPct !== null && !isWorking && Math.round(ctxPct) >= COMPACT_LOUD_PCT
307 return { key: 'compact', label: 'Compact', hotkey: 'c', plain: !loud }
308}
309
310export const CLEAR_BUTTON: BandButton = { key: 'clear', label: 'Clear', hotkey: 'x', plain: true }
311export const PROGRESS_BUTTON: BandButton = { key: 'progress', label: 'Progress', hotkey: 'p', plain: true }
312export const LOG_BUTTON: BandButton = { key: 'log', label: 'Log', hotkey: 'l', plain: true }
313
314export type CompactOutcome = { skip?: string; tokensBefore?: number; tokensAfter?: number }
315
316export function compactToast(r: CompactOutcome): string {
317 if (r.skip !== undefined) return `compact skipped: ${r.skip}`
318 const n = (v: number | undefined) => (v === undefined ? '?' : fmtInt(v))
319 return `compacted ${n(r.tokensBefore)} → ${n(r.tokensAfter)} tokens`
320}
321
322export type BandInput = {
323 usage: BandUsage
324 model: string | null
325 cache: BandCache
326 nowMs: number
327 isWorking: boolean
328 digest: string | null
329 /** a task checklist is published: the Progress button is drawn only then */
330 hasChecklist: boolean
331 /** some event list is non-empty: the Log button is drawn only then */
332 hasEvents: boolean
333}
334
335/** `claude-fable-5-1` → `fable-5-1`: every model id carries the prefix, so it says nothing. */
336export function shortModel(m: string | null): string | null {
337 return m === null ? null : m.replace(/^claude-/, '')
338}
339
340/** The ids the terminal's model picker offers, as `/model <id>` takes them; the engine lists no models itself. */
341export const MODEL_IDS = ['claude-fable-5-1', 'claude-opus-5-5', 'claude-sonnet-5-5', 'claude-haiku-4-5-20251001'] as const
342
343/** The mark after the model id on the terminal: the segment is the picker's button. */
344export const MODEL_MARK = ' ▾'
345
346export type ModelOption = { value: string; label: string; hotkey?: string }
347
348/**
349 * The picker's options: the known ids plus the session's own when it is none of them (a pinned or dated id).
350 * Each takes its label's first letter as hotkey when it is one and still free (`f`, `o`, `s`, `h`).
351 */
352export function modelOptions(current: string | null): ModelOption[] {
353 const ids: string[] = current !== null && !MODEL_IDS.includes(current as (typeof MODEL_IDS)[number]) ? [current, ...MODEL_IDS] : [...MODEL_IDS]
354 const taken = new Set<string>()
355 return ids.map(value => {
356 const label = shortModel(value) as string
357 const letter = label[0]
358 if (!/^[a-z]$/.test(letter) || taken.has(letter)) return { value, label }
359 taken.add(letter)
360 return { value, label, hotkey: letter }
361 })
362}
363
364/** `cache 42m` → [`cache`, `42m`]: the dim label and the bold value; no space → the whole text is the label. */
365export function splitLabel(text: string): [string, string] {
366 const i = text.indexOf(' ')
367 return i < 0 ? [text, ''] : [text.slice(0, i), text.slice(i + 1)]
368}
369
370/** Every segment before the width drop. */
371export function bandSegs(i: BandInput): BandSegs {
372 return {
373 cache: i.usage.rates.some(r => r.pct >= 100) ? null : cacheView(i.cache, i.nowMs, i.isWorking).text,
374 model: shortModel(i.model),
375 ctx: ctxText(i.usage.ctxPct),
376 rates: i.usage.rates,
377 cost: costText(i.usage.costUsd),
378 digest: i.digest,
379 compact: compactButton(i.usage.ctxPct, i.isWorking),
380 clear: CLEAR_BUTTON,
381 progress: i.hasChecklist ? PROGRESS_BUTTON : null,
382 log: i.hasEvents ? LOG_BUTTON : null,
383 }
384}
385
386export const HOUR_MS = HOUR
387
388/** `ephemeral_1h_input_tokens` of an Agent result's usage; 0 when absent. */
389export function oneHourCacheTokens(result: unknown): number {
390 const usage = (result as { usage?: { cache_creation?: { ephemeral_1h_input_tokens?: unknown } | null } } | null)?.usage
391 const n = usage?.cache_creation?.ephemeral_1h_input_tokens
392 return typeof n === 'number' ? n : 0
393}
394
395/** The two plugins that publish a task snapshot; fnd and base never run together. */
396export type Publisher = 'base' | 'fnd'
397
398/** The publisher's own skill in each hint: a base task is saved by base's, an fnd task by fnd's. */
399export const HINTS: Record<Publisher, { workspace: string; progress: string }> = {
400 base: { workspace: 'no task workspace — /base:save-task-context', progress: 'no progress.md yet — /base:save-task-context' },
401 fnd: { workspace: 'no task workspace — /fnd:save-task-context', progress: 'no progress.md yet — /fnd:save-task-context' },
402}
403
404/** base.progress ?? fnd.progress: the first non-null snapshot and who wrote it; null when neither publishes. */
405export function pickProgress(base: unknown, fnd: unknown): { snapshot: unknown; publisher: Publisher } | null {
406 if (base !== null && base !== undefined) return { snapshot: base, publisher: 'base' }
407 if (fnd !== null && fnd !== undefined) return { snapshot: fnd, publisher: 'fnd' }
408 return null
409}
410
411const MARKS: ReadonlySet<string> = new Set<ChecklistMark>(['done', 'current', 'waiting', 'todo'])
412
413type Snapshot = Record<string, unknown> & { workId: string }
414
415/** Another plugin writes the snapshot, so every field is checked: a value that is not a finite number reads 0. */
416function snapshotOf(p: unknown): Snapshot | null {
417 if (p === null || typeof p !== 'object') return null
418 const s = p as Record<string, unknown>
419 return typeof s.workId === 'string' && s.workId !== '' ? (s as Snapshot) : null
420}
421
422const num = (x: unknown): number => (typeof x === 'number' && Number.isFinite(x) ? x : 0)
423
424/** The published task as the generic checklist the Progress pane draws, hints naming `publisher`'s skill; null while no task resolves. */
425export function toChecklist(p: unknown, publisher: Publisher = 'fnd'): Checklist | null {
426 const s = snapshotOf(p)
427 if (s === null) return null
428 const done = num(s.done)
429 const total = num(s.total)
430 const branch = typeof s.branch === 'string' && s.branch !== '' ? s.branch : null
431 const subtitle = [branch, total ? `${done}/${total}` : null].filter(Boolean).join(' · ')
432 const rows: ChecklistRow[] = (Array.isArray(s.rows) ? s.rows : [])
433 .filter((r): r is { mark?: unknown; text: string } => r !== null && typeof r === 'object' && typeof (r as { text?: unknown }).text === 'string')
434 .map(r => ({ mark: typeof r.mark === 'string' && MARKS.has(r.mark) ? (r.mark as ChecklistMark) : 'todo', text: r.text }))
435 const hint = s.hasWorkspace === false ? HINTS[publisher].workspace : total === 0 ? HINTS[publisher].progress : null
436 const notes = Array.isArray(s.notesTail) ? s.notesTail.filter((l): l is string => typeof l === 'string') : []
437 const footer = [...(hint === null ? [] : [hint]), ...notes]
438 return { v: 1, title: s.workId, ...(subtitle ? { subtitle } : {}), rows, ...(footer.length ? { footer } : {}) }
439}
440
441/** Band digest: `ELC-1591 3/5 ▶ Preview themes`, `ELC-1591 ✓ 5/5` when every row is checked, the id alone with no rows. */
442export function digestOf(p: unknown): string | null {
443 const s = snapshotOf(p)
444 if (s === null) return null
445 const done = num(s.done)
446 const total = num(s.total)
447 const current = typeof s.current === 'string' ? s.current : null
448 if (total === 0) return s.workId
449 if (current === null) return `${s.workId} ✓ ${done}/${total}`
450 return `${s.workId} ${done}/${total} ▶ ${current}`
451}
452hooks/mods/events.ts 161 lines1// Pure event-log and pane helpers: band's own ring buffer, the merge of every publisher's list, the
2// pane's row model and the lines of band's file on disk. No `$` here: the writer keeps its own
3// `logEvent` wrapper, as the validator follows `$` only within one file.
4import type { BandEvent, ForeignEvent, LogLine, LogSource, TeamSource } from '../../types'
5
6export const EVENT_CAP = 200
7export const LOG_PANE = 'band-log'
8export const LOG_COMMAND = { name: 'band-log', description: 'Open the band event log pane', immediate: true } as const
9export const CHECKLIST_PANE = 'band-progress'
10export const CHECKLIST_COMMAND = { name: 'band-progress', description: 'Open the band task checklist pane', immediate: true } as const
11export const DEBUG_COMMAND = { name: 'band-debug', description: 'Show the raw figures behind the status band (debug)' } as const
12
13/** Appends `ev`, oldest first, at most EVENT_CAP: past the cap the oldest line goes. */
14export function pushEvent(list: readonly BandEvent[], ev: BandEvent): BandEvent[] {
15 return list.length < EVENT_CAP ? [...list, ev] : [...list.slice(1), ev]
16}
17
18/** The kinds band writes itself; an fnd that predates the yield writes them too, and they would show twice. Dropped from base's list as well. */
19const OWN_KINDS: ReadonlySet<string> = new Set(['session', 'model', 'compact', 'rate'])
20
21/** Another plugin writes the list: an entry without a finite `atMs` or a string `kind`/`text` is dropped, extra fields too. */
22export function take(list: unknown): ForeignEvent[] {
23 if (!Array.isArray(list)) return []
24 const out: ForeignEvent[] = []
25 for (const ev of list) {
26 const e = ev as Partial<ForeignEvent> | null
27 if (e && typeof e === 'object' && Number.isFinite(e.atMs) && typeof e.kind === 'string' && typeof e.text === 'string') {
28 out.push({ atMs: e.atMs as number, kind: e.kind, text: e.text })
29 }
30 }
31 return out
32}
33
34const tag = (plugin: LogSource) => (e: ForeignEvent): LogLine => ({ ...e, plugin })
35
36/** The team plugins on base, in the order their lines follow slim's on a tie. */
37export const TEAM_SOURCES: readonly TeamSource[] = ['fe', 'qa', 'be', 'pm']
38
39/**
40 * band's, base's, fnd's, slim's and the team plugins' lines in one list, oldest first, each tagged with the
41 * list it came from; on equal times band → base → fnd → slim → fe → qa → be → pm, each list in its own order.
42 * Beside slim's lines fnd's own compression lines read `fnd-slim`.
43 */
44export function merged(own: unknown, base: unknown, fnd: unknown, slim: unknown, teams: Partial<Record<TeamSource, unknown>> = {}): LogLine[] {
45 const s = take(slim).map(tag('slim'))
46 const c = take(base).filter(e => !OWN_KINDS.has(e.kind)).map(tag('base'))
47 const f = take(fnd)
48 .filter(e => !OWN_KINDS.has(e.kind))
49 .map(e => (s.length && e.kind === 'slim' ? { ...e, kind: 'fnd-slim' } : e))
50 .map(tag('fnd'))
51 const t = TEAM_SOURCES.flatMap(p => take(teams[p]).map(tag(p)))
52 return [...take(own).map(tag('band')), ...c, ...f, ...s, ...t].map((e, i) => ({ e, i })).sort((a, b) => a.e.atMs - b.e.atMs || a.i - b.i).map(x => x.e)
53}
54
55const pad2 = (n: number) => String(n).padStart(2, '0')
56
57/** Local `09:05`. */
58export function hhmm(ms: number): string {
59 const d = new Date(ms)
60 return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
61}
62
63/** 412_345 → `412k`; below 1000 the number as is. */
64export function fmtK(n: number): string {
65 return n < 1000 ? String(n) : `${Math.round(n / 1000)}k`
66}
67
68/** The source list padded to 5, so the kinds line up. */
69export function pluginCell(plugin: string): string {
70 return plugin.padEnd(5)
71}
72
73/** The kind padded to the longest kind, `workspace`, so the texts line up. */
74export function kindCell(kind: string): string {
75 return kind.padEnd(9)
76}
77
78/** One line of `/band-log`'s text answer: `09:05 base guard Bash: --no-verify`. */
79export function logRow(ev: LogLine): string {
80 return `${hhmm(ev.atMs)} ${pluginCell(ev.plugin)} ${kindCell(ev.kind)} ${ev.text}`
81}
82
83/** Cells the time, plugin and kind columns take before the text: `09:05 `, `base ` and `workspace `. */
84export const PREFIX_COLS = 7 + 7 + 9 + 2
85
86/** Rows one text takes wrapped at `cols` cells, at least one. */
87export function textRows(text: string, cols: number): number {
88 return Math.max(1, Math.ceil(text.length / Math.max(1, cols)))
89}
90
91/**
92 * The newest events whose wrapped rows fit in `rows` (all of them when `rows` is 0 or they all fit);
93 * when some are left out, one row is kept for the `… N earlier` line.
94 */
95export function newestFitting<T extends { text: string }>(list: readonly T[], rows: number, cols: number): T[] {
96 if (rows <= 0) return [...list]
97 let used = 0
98 let i = list.length
99 while (i > 0 && used + textRows(list[i - 1]!.text, cols) <= rows) used += textRows(list[--i]!.text, cols)
100 if (i === 0) return [...list]
101 while (i < list.length && used > rows - 1) used -= textRows(list[i++]!.text, cols)
102 return list.slice(i)
103}
104
105export const FILE_CAP_LINES = 2000
106export const FILE_CAP_BYTES = 256 * 1024
107
108const trimSlash = (p: string) => p.replace(/\/+$/, '')
109
110/**
111 * `<DOMAINE_LOG_DIR>/<session>` when the override is absolute, else `$HOME/.claude/domaine/log/<session>`; null
112 * with neither, or for a session id that is no plain name. Same rule as base and slim, so one session's files
113 * share one directory.
114 */
115export function logDir(home: string | undefined, override: string | undefined, sessionId: string): string | null {
116 if (!/^[\w.-]+$/.test(sessionId) || /^\.+$/.test(sessionId)) return null
117 const o = override?.trim() ?? ''
118 const h = home?.trim() ?? ''
119 const root = o.startsWith('/') ? trimSlash(o) : h.startsWith('/') ? `${trimSlash(h)}/.claude/domaine/log` : null
120 return root === null ? null : `${root}/${sessionId}`
121}
122
123/** One line of `band.jsonl`; `plugin` is band's own name, never taken from another plugin's state. */
124export function fileLine(atMs: number, version: string, session: string, kind: string, text: string): string {
125 return JSON.stringify({ ts: new Date(atMs).toISOString(), plugin: 'band', version, session, kind, agent: 'main', text })
126}
127
128/** The lines of an existing file that belong to `session`; anything unparsable or another session's goes. */
129export function seedLines(fileText: string, session: string): string[] {
130 return fileText.split('\n').filter(l => {
131 try {
132 return (JSON.parse(l) as { session?: unknown }).session === session
133 } catch {
134 return false
135 }
136 })
137}
138
139function utf8Bytes(s: string): number {
140 let n = 0
141 for (let i = 0; i < s.length; i++) {
142 const c = s.charCodeAt(i)
143 if (c < 0x80) n += 1
144 else if (c < 0x800) n += 2
145 else if (c >= 0xd800 && c < 0xdc00) {
146 n += 4
147 i++
148 } else n += 3
149 }
150 return n
151}
152
153/** Oldest lines dropped until at most FILE_CAP_LINES remain and the file, newlines counted, fits FILE_CAP_BYTES. */
154export function capLines(lines: readonly string[]): string[] {
155 const out = lines.slice(-FILE_CAP_LINES)
156 let bytes = out.reduce((n, l) => n + utf8Bytes(l) + 1, 0)
157 let drop = 0
158 while (bytes > FILE_CAP_BYTES && drop < out.length) bytes -= utf8Bytes(out[drop++]!) + 1
159 return out.slice(drop)
160}
161types/index.d.ts 93 lines1// The band plugin's $.state contract: the keys band owns and writes, and the other plugins' keys it reads,
2// typed locally (no `dependencies`). Values are JSON; a value never written reads as the atom's initial.
3
4/** One rate-limit window as the band draws it; `pct` is the raw percentUsed (may pass 100). */
5export type BandRate = { kind: string; label: string; pct: number; resetsAt: string | null }
6
7/** Context, rate and cost figures; `ctxPct`/`ctxTokens` are null until the first reading and after a compaction; `costUsd` is null where the host keeps no ledger or BAND_COST is off. */
8export type BandUsage = { ctxPct: number | null; ctxTokens: number | null; window: number; rates: BandRate[]; costUsd: number | null }
9
10/** Prompt-cache estimate: `anchorMs` = clock time of the last main-thread response, null before one. */
11export type BandCache = {
12 anchorMs: number | null
13 ttlMs: number
14 ttlSource: 'option' | 'store' | 'model-switch' | 'agent' | 'subscription' | 'resume' | 'default'
15 isCold: boolean
16}
17
18export type BandEventKind = 'session' | 'model' | 'compact' | 'rate'
19/** One of band's own event-log lines; `atMs` = `$.clock.now()` when written, `text` is one line. */
20export type BandEvent = { atMs: number; kind: BandEventKind; text: string }
21
22/** Written at every session.start of band, so non-null iff band is loaded; `disabled` = userConfig `disabled`. */
23export type BandInfo = { v: 1; version: string; disabled: boolean }
24
25/** A line from another plugin's event log; band reads only these fields and drops an entry missing one. */
26export type ForeignEvent = { atMs: number; kind: string; text: string }
27
28/** A team plugin on base whose event list band reads. */
29export type TeamSource = 'fe' | 'qa' | 'be' | 'pm'
30/** The list a log line came from: the PLUGIN column of the Log pane and `/band-log`. */
31export type LogSource = 'band' | 'base' | 'fnd' | 'slim' | TeamSource
32/** One line of the merged log, tagged with its source list. */
33export type LogLine = ForeignEvent & { plugin: LogSource }
34
35export type ChecklistMark = 'done' | 'current' | 'waiting' | 'todo'
36export type ChecklistRow = { mark: ChecklistMark; text: string }
37/** The generic shape the Progress pane draws; band maps the published task snapshot into it. */
38export type Checklist = { v: 1; title: string; subtitle?: string; rows: ChecklistRow[]; footer?: string[] }
39
40/** A publisher's resolved task (base's BaseProgress, fnd's FndProgress) as band reads it; every field is checked before use. */
41export type ProgressSnapshot =
42 | { workId: string; branch: string | null; hasWorkspace: boolean; done: number; total: number; current: string | null; rows: ChecklistRow[]; notesTail: string[]; mtimeMs: number }
43 | { workId: null; branch: string | null }
44
45declare module 'claude-code' {
46 interface PluginState {
47 band: {
48 info: BandInfo | null
49 usage: BandUsage
50 model: string | null
51 cache: BandCache
52 /** clock time of the last refresh; 0 until the first session.start of this process */
53 tick: number
54 rateAlarmed: boolean
55 /** the band-progress pane is placed and not closed; the band hides its digest then */
56 paneShown: boolean
57 /** the band holds the keyboard; hotkey letters are drawn only then */
58 bandFocused: boolean
59 /** the terminal's model picker is unfolded: the band row holds the models alone */
60 modelPicker: boolean
61 /** band's own event lines, oldest first, at most 200; stays [] under BAND_EVENT_LOG=0 */
62 events: BandEvent[]
63 }
64 /** Owned and written by the base plugin; band only reads it (null / [] without base). */
65 base: {
66 events: ForeignEvent[]
67 progress: ProgressSnapshot | null
68 }
69 /** Owned and written by the fnd plugin; band only reads it (null / [] without fnd). */
70 fnd: {
71 events: ForeignEvent[]
72 progress: ProgressSnapshot | null
73 }
74 /** Owned and written by the slim plugin; band only reads it. */
75 slim: {
76 events: ForeignEvent[]
77 }
78 /** Owned and written by the team plugins (fe, qa, be, pm); band only reads their event lists ([] without the plugin). */
79 fe: {
80 events: ForeignEvent[]
81 }
82 qa: {
83 events: ForeignEvent[]
84 }
85 be: {
86 events: ForeignEvent[]
87 }
88 pm: {
89 events: ForeignEvent[]
90 }
91 }
92}
93